Implement Phase 1: Foundation

- Set up TypeScript project with strict mode
- Configure tsup build system (ESM output)
- Configure vitest for testing (36 tests passing)
- Configure ESLint with flat config for TypeScript
- Implement Commander.js CLI with all 4 commands:
  - init: Configure API keys with validation
  - new: Create new Ralph Method project (stub)
  - validate: Validate PROMPT.md files
  - research: Research topics via Perplexity (stub)
- Implement config management:
  - Keys stored in ~/.ralph-generator/config.json
  - File permissions set to 600
  - Environment variables override file config
- Implement logging utility with verbosity levels
- Implement atomic file writes utility

All Phase 1 acceptance criteria met.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-01-10 12:08:24 +00:00
parent 688cfe57ed
commit 606f27d6bb
22 changed files with 5262 additions and 6 deletions

View File

@@ -0,0 +1,37 @@
---
active: true
iteration: 1
max_iterations: 30
completion_promise: "PHASE_1_COMPLETE"
started_at: "2026-01-10T12:00:45Z"
---
Execute Phase 1 - Foundation from PROMPT.md.
You are building the Ralph PRD Generator CLI tool.
Read PROMPT.md for full specification.
Read prd.json for feature tracking.
Read CLAUDE.md for project configuration.
Phase 1 tasks:
1. Set up TypeScript project with all dependencies
2. Configure tsup, vitest, eslint
3. Implement CLI framework with Commander.js
4. Implement config management for API key storage
5. Create basic project structure
6. Write tests for config and CLI
For each feature:
1. Write tests first
2. Implement the feature
3. Run: npm run build && npm run test && npm run lint
4. If all pass, update prd.json by setting passes to true for completed features
5. Commit with descriptive message
6. Append progress to progress.txt
When ALL Phase 1 features in prd.json have passes set to true:
Output <promise>PHASE_1_COMPLETE</promise>
If blocked after 10 attempts on same issue:
Document in progress.txt and output <promise>PHASE_1_BLOCKED</promise>

15
eslint.config.js Normal file
View File

@@ -0,0 +1,15 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['dist/**', 'node_modules/**', '*.config.js'],
},
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
}
);

3939
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,18 +7,43 @@
"ralph-vibe": "./dist/index.js" "ralph-vibe": "./dist/index.js"
}, },
"scripts": { "scripts": {
"build": "tsup src/index.ts --format esm --dts", "build": "tsup",
"dev": "tsup src/index.ts --format esm --watch", "dev": "tsup --watch",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "eslint src --ext .ts", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build && npm run test && npm run lint" "prepublishOnly": "npm run build && npm run test && npm run lint"
}, },
"keywords": ["cli", "ai", "claude", "vibe-coding", "ralph-method"], "keywords": [
"cli",
"ai",
"claude",
"vibe-coding",
"ralph-method"
],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"chalk": "^5.6.2",
"commander": "^14.0.2",
"inquirer": "^13.1.0",
"ora": "^9.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/inquirer": "^9.0.9",
"@types/node": "^25.0.5",
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.52.0",
"vitest": "^4.0.16"
} }
} }

View File

@@ -8,7 +8,7 @@
"name": "API Key Management", "name": "API Key Management",
"description": "Store and retrieve API keys securely", "description": "Store and retrieve API keys securely",
"priority": 1, "priority": 1,
"passes": false, "passes": true,
"acceptance": "ralph-vibe init stores keys and ralph-vibe new fails gracefully without keys" "acceptance": "ralph-vibe init stores keys and ralph-vibe new fails gracefully without keys"
}, },
{ {
@@ -17,7 +17,7 @@
"name": "CLI Framework", "name": "CLI Framework",
"description": "Commander.js setup with all commands stubbed", "description": "Commander.js setup with all commands stubbed",
"priority": 2, "priority": 2,
"passes": false, "passes": true,
"acceptance": "ralph-vibe --help shows all commands, ralph-vibe init/new/validate/research exist" "acceptance": "ralph-vibe --help shows all commands, ralph-vibe init/new/validate/research exist"
}, },
{ {

View File

@@ -4,3 +4,17 @@
--- ---
[2026-01-10T12:08:00Z] [1] [COMPLETE] - Phase 1 Foundation implemented
- TypeScript project with strict mode configured
- tsup build system with ESM output
- vitest testing framework (36 tests passing)
- ESLint flat config with TypeScript support
- Commander.js CLI framework with all 4 commands
- Config management for API keys (~/.ralph-generator/config.json)
- Keys stored with 600 permissions, env vars override file config
- Validation command works (tested on PROMPT.md)
- All Phase 1 acceptance criteria met:
* ralph-vibe --help shows all commands
* ralph-vibe init/new/validate/research exist
* ralph-vibe new fails gracefully without keys

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { join } from 'path';
import { mkdir, rm, readFile, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
// Mock the home directory before importing config
const testDir = join(tmpdir(), `ralph-test-${Date.now()}`);
const mockConfigDir = join(testDir, '.ralph-generator');
vi.mock('os', async () => {
const actual = await vi.importActual('os');
return {
...actual,
homedir: () => testDir,
};
});
// Import after mocking
const { loadConfig, saveConfig, clearConfig, hasValidKeys, getConfigDir, getConfigFile } = await import('../utils/config.js');
describe('Config Management', () => {
beforeEach(async () => {
await mkdir(testDir, { recursive: true });
// Clear environment variables for tests
delete process.env.CLAUDE_API_KEY;
delete process.env.PERPLEXITY_API_KEY;
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe('getConfigDir', () => {
it('returns the correct config directory', () => {
expect(getConfigDir()).toBe(mockConfigDir);
});
});
describe('getConfigFile', () => {
it('returns the correct config file path', () => {
expect(getConfigFile()).toBe(join(mockConfigDir, 'config.json'));
});
});
describe('loadConfig', () => {
it('returns empty config when no file exists and no env vars', async () => {
const config = await loadConfig();
expect(config).toEqual({
claudeApiKey: undefined,
perplexityApiKey: undefined,
});
});
it('loads config from file', async () => {
await mkdir(mockConfigDir, { recursive: true });
await writeFile(
join(mockConfigDir, 'config.json'),
JSON.stringify({
claudeApiKey: 'test-claude-key',
perplexityApiKey: 'test-perplexity-key',
})
);
const config = await loadConfig();
expect(config.claudeApiKey).toBe('test-claude-key');
expect(config.perplexityApiKey).toBe('test-perplexity-key');
});
it('environment variables override file config', async () => {
await mkdir(mockConfigDir, { recursive: true });
await writeFile(
join(mockConfigDir, 'config.json'),
JSON.stringify({
claudeApiKey: 'file-key',
perplexityApiKey: 'file-perplexity-key',
})
);
process.env.CLAUDE_API_KEY = 'env-key';
const config = await loadConfig();
expect(config.claudeApiKey).toBe('env-key');
expect(config.perplexityApiKey).toBe('file-perplexity-key');
});
});
describe('saveConfig', () => {
it('saves config to file', async () => {
await saveConfig({
claudeApiKey: 'my-claude-key',
perplexityApiKey: 'my-perplexity-key',
});
const content = await readFile(join(mockConfigDir, 'config.json'), 'utf-8');
const saved = JSON.parse(content);
expect(saved.claudeApiKey).toBe('my-claude-key');
expect(saved.perplexityApiKey).toBe('my-perplexity-key');
});
it('creates config directory if it does not exist', async () => {
await saveConfig({ claudeApiKey: 'test' });
const content = await readFile(join(mockConfigDir, 'config.json'), 'utf-8');
expect(JSON.parse(content).claudeApiKey).toBe('test');
});
});
describe('clearConfig', () => {
it('clears the config file', async () => {
await mkdir(mockConfigDir, { recursive: true });
await writeFile(
join(mockConfigDir, 'config.json'),
JSON.stringify({ claudeApiKey: 'key' })
);
await clearConfig();
const content = await readFile(join(mockConfigDir, 'config.json'), 'utf-8');
expect(JSON.parse(content)).toEqual({});
});
it('does not throw if no config exists', async () => {
await expect(clearConfig()).resolves.not.toThrow();
});
});
describe('hasValidKeys', () => {
it('returns false for both when no keys', async () => {
const result = await hasValidKeys();
expect(result.claude).toBe(false);
expect(result.perplexity).toBe(false);
});
it('returns true when keys exist', async () => {
await mkdir(mockConfigDir, { recursive: true });
await writeFile(
join(mockConfigDir, 'config.json'),
JSON.stringify({
claudeApiKey: 'key',
perplexityApiKey: 'key',
})
);
const result = await hasValidKeys();
expect(result.claude).toBe(true);
expect(result.perplexity).toBe(true);
});
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { join } from 'path';
import { mkdir, rm, writeFile as fsWriteFile, readFile } from 'fs/promises';
import { tmpdir } from 'os';
import { fileExists, readFileContent, writeFileAtomic, readJsonFile, writeJsonFile, ensureDir } from '../utils/files.js';
describe('File Utilities', () => {
const testDir = join(tmpdir(), `ralph-files-test-${Date.now()}`);
beforeEach(async () => {
await mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe('ensureDir', () => {
it('creates directory if it does not exist', async () => {
const newDir = join(testDir, 'new', 'nested', 'dir');
await ensureDir(newDir);
expect(await fileExists(newDir)).toBe(true);
});
it('does not throw if directory exists', async () => {
await expect(ensureDir(testDir)).resolves.not.toThrow();
});
});
describe('fileExists', () => {
it('returns true for existing file', async () => {
const filePath = join(testDir, 'test.txt');
await fsWriteFile(filePath, 'test');
expect(await fileExists(filePath)).toBe(true);
});
it('returns false for non-existing file', async () => {
expect(await fileExists(join(testDir, 'nonexistent.txt'))).toBe(false);
});
});
describe('readFileContent', () => {
it('reads file content', async () => {
const filePath = join(testDir, 'read-test.txt');
await fsWriteFile(filePath, 'hello world');
const content = await readFileContent(filePath);
expect(content).toBe('hello world');
});
});
describe('writeFileAtomic', () => {
it('writes file atomically', async () => {
const filePath = join(testDir, 'atomic.txt');
await writeFileAtomic(filePath, 'atomic content');
const content = await readFile(filePath, 'utf-8');
expect(content).toBe('atomic content');
});
it('creates parent directories', async () => {
const filePath = join(testDir, 'new', 'deep', 'file.txt');
await writeFileAtomic(filePath, 'nested');
expect(await fileExists(filePath)).toBe(true);
});
});
describe('readJsonFile', () => {
it('reads and parses JSON file', async () => {
const filePath = join(testDir, 'data.json');
await fsWriteFile(filePath, JSON.stringify({ name: 'test', value: 42 }));
const data = await readJsonFile<{ name: string; value: number }>(filePath);
expect(data.name).toBe('test');
expect(data.value).toBe(42);
});
});
describe('writeJsonFile', () => {
it('writes JSON file with formatting', async () => {
const filePath = join(testDir, 'output.json');
await writeJsonFile(filePath, { key: 'value' });
const content = await readFile(filePath, 'utf-8');
expect(content).toContain('"key"');
expect(content).toContain('"value"');
// Check it's formatted (has newlines)
expect(content).toContain('\n');
});
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { logger, setLogLevel, getLogLevel } from '../utils/logger.js';
describe('Logger', () => {
const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
beforeEach(() => {
mockConsoleLog.mockClear();
mockConsoleError.mockClear();
setLogLevel('info');
});
afterEach(() => {
setLogLevel('info');
});
describe('setLogLevel and getLogLevel', () => {
it('sets and gets log level', () => {
setLogLevel('debug');
expect(getLogLevel()).toBe('debug');
setLogLevel('error');
expect(getLogLevel()).toBe('error');
});
});
describe('debug', () => {
it('does not log at info level', () => {
setLogLevel('info');
logger.debug('test message');
expect(mockConsoleLog).not.toHaveBeenCalled();
});
it('logs at debug level', () => {
setLogLevel('debug');
logger.debug('test message');
expect(mockConsoleLog).toHaveBeenCalled();
});
});
describe('info', () => {
it('logs at info level', () => {
setLogLevel('info');
logger.info('test message');
expect(mockConsoleLog).toHaveBeenCalled();
});
it('does not log at warn level', () => {
setLogLevel('warn');
logger.info('test message');
expect(mockConsoleLog).not.toHaveBeenCalled();
});
});
describe('warn', () => {
it('logs at info level', () => {
setLogLevel('info');
logger.warn('test message');
expect(mockConsoleLog).toHaveBeenCalled();
});
it('does not log at error level', () => {
setLogLevel('error');
logger.warn('test message');
expect(mockConsoleLog).not.toHaveBeenCalled();
});
});
describe('error', () => {
it('always logs at any level', () => {
setLogLevel('error');
logger.error('test message');
expect(mockConsoleError).toHaveBeenCalled();
});
});
describe('success', () => {
it('always logs regardless of level', () => {
setLogLevel('error');
logger.success('test message');
expect(mockConsoleLog).toHaveBeenCalled();
});
});
describe('log', () => {
it('always logs regardless of level', () => {
setLogLevel('error');
logger.log('test message');
expect(mockConsoleLog).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { validatePromptMd } from '../commands/validate.js';
describe('validatePromptMd', () => {
const validPromptMd = `
# Project: Test App
## Objective
A test application for validation.
## Application Type
CLI tool
## Tech Stack
- Language: TypeScript
- Runtime: Node.js
## Completion Criteria
1. \`npm run build\` exits 0
2. \`npm run test\` exits 0
<promise>PROJECT_COMPLETE</promise>
`;
it('validates a correct PROMPT.md', () => {
const result = validatePromptMd(validPromptMd);
expect(result.valid).toBe(true);
expect(result.issues.filter(i => i.type === 'error')).toHaveLength(0);
});
it('detects missing required sections', () => {
const content = `
# Project: Test
Some content without proper sections.
`;
const result = validatePromptMd(content);
expect(result.valid).toBe(false);
expect(result.issues.some(i => i.message.includes('Missing required section: Objective'))).toBe(true);
expect(result.issues.some(i => i.message.includes('Missing required section: Tech Stack'))).toBe(true);
});
it('warns about missing promise tags', () => {
const content = `
## Objective
Test
## Application Type
CLI
## Tech Stack
Node.js
## Completion Criteria
\`npm test\`
`;
const result = validatePromptMd(content);
expect(result.issues.some(i => i.message.includes('No promise tags'))).toBe(true);
expect(result.suggestions.some(s => s.includes('promise'))).toBe(true);
});
it('warns about ambiguous language', () => {
const content = `
## Objective
The app should work well.
## Application Type
Web
## Tech Stack
Node.js
## Completion Criteria
The tests might pass.
<promise>DONE</promise>
`;
const result = validatePromptMd(content);
expect(result.issues.some(i => i.message.includes('Ambiguous language'))).toBe(true);
});
it('warns about missing verification commands', () => {
const content = `
## Objective
Test app
## Application Type
CLI
## Tech Stack
Node.js
## Completion Criteria
1. Build works
2. Tests pass
<promise>DONE</promise>
`;
const result = validatePromptMd(content);
expect(result.issues.some(i => i.message.includes('missing verification commands'))).toBe(true);
});
it('passes when completion criteria has commands', () => {
const result = validatePromptMd(validPromptMd);
expect(result.issues.some(i => i.message.includes('missing verification commands'))).toBe(false);
});
});

115
src/commands/init.ts Normal file
View File

@@ -0,0 +1,115 @@
import inquirer from 'inquirer';
import Anthropic from '@anthropic-ai/sdk';
import { saveConfig, loadConfig, clearConfig } from '../utils/config.js';
import { logger } from '../utils/logger.js';
import type { Config } from '../types/index.js';
interface InitOptions {
claudeKey?: string;
perplexityKey?: string;
reset?: boolean;
}
async function validateClaudeKey(key: string): Promise<boolean> {
try {
const client = new Anthropic({ apiKey: key });
// Make a minimal API call to validate the key
await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 10,
messages: [{ role: 'user', content: 'Hi' }],
});
return true;
} catch (err) {
const error = err as Error;
logger.debug(`Claude key validation failed: ${error.message}`);
return false;
}
}
async function validatePerplexityKey(key: string): Promise<boolean> {
try {
const response = await fetch('https://api.perplexity.ai/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'sonar',
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 10,
}),
});
return response.ok;
} catch (err) {
const error = err as Error;
logger.debug(`Perplexity key validation failed: ${error.message}`);
return false;
}
}
export async function initCommand(options: InitOptions): Promise<void> {
if (options.reset) {
await clearConfig();
logger.success('Configuration has been reset.');
return;
}
const existingConfig = await loadConfig();
const config: Config = { ...existingConfig };
// Handle Claude API key
if (options.claudeKey) {
config.claudeApiKey = options.claudeKey;
} else if (!config.claudeApiKey) {
const { claudeKey } = await inquirer.prompt<{ claudeKey: string }>([
{
type: 'password',
name: 'claudeKey',
message: 'Enter your Claude API key:',
mask: '*',
validate: (input: string) => input.length > 0 || 'API key is required',
},
]);
config.claudeApiKey = claudeKey;
}
// Handle Perplexity API key
if (options.perplexityKey) {
config.perplexityApiKey = options.perplexityKey;
} else if (!config.perplexityApiKey) {
const { perplexityKey } = await inquirer.prompt<{ perplexityKey: string }>([
{
type: 'password',
name: 'perplexityKey',
message: 'Enter your Perplexity API key:',
mask: '*',
validate: (input: string) => input.length > 0 || 'API key is required',
},
]);
config.perplexityApiKey = perplexityKey;
}
// Validate keys
logger.info('Validating API keys...');
const claudeValid = await validateClaudeKey(config.claudeApiKey!);
if (!claudeValid) {
logger.error('Claude API key is invalid. Please check your key and try again.');
process.exit(1);
}
logger.success('Claude API key is valid.');
const perplexityValid = await validatePerplexityKey(config.perplexityApiKey!);
if (!perplexityValid) {
logger.error('Perplexity API key is invalid. Please check your key and try again.');
process.exit(1);
}
logger.success('Perplexity API key is valid.');
// Save config
await saveConfig(config);
logger.success('Configuration saved successfully!');
logger.info('You can now use `ralph-vibe new <project-name>` to create a new project.');
}

47
src/commands/new.ts Normal file
View File

@@ -0,0 +1,47 @@
import { loadConfig, hasValidKeys } from '../utils/config.js';
import { logger } from '../utils/logger.js';
interface NewOptions {
ideaFile?: string;
outputDir?: string;
skipResearch?: boolean;
skipConfirm?: boolean;
verbose?: boolean;
dryRun?: boolean;
}
export async function newCommand(projectName: string, options: NewOptions): Promise<void> {
// Check for valid API keys
const keys = await hasValidKeys();
if (!keys.claude) {
logger.error('Claude API key not found.');
logger.info('Run `ralph-vibe init` to configure your API keys.');
process.exit(1);
}
if (!keys.perplexity && !options.skipResearch) {
logger.error('Perplexity API key not found.');
logger.info('Run `ralph-vibe init` to configure your API keys.');
logger.info('Or use --skip-research to skip the research phase.');
process.exit(1);
}
const config = await loadConfig();
logger.debug('Config loaded', { hasClaudeKey: !!config.claudeApiKey });
if (options.verbose) {
logger.setLevel('debug');
}
logger.info(`Creating new project: ${projectName}`);
if (options.dryRun) {
logger.info('[DRY RUN] Would create project at:', options.outputDir || `./${projectName}`);
return;
}
// TODO: Implement full generation pipeline in Phase 3
logger.info('Project generation will be implemented in Phase 3.');
logger.info('Current Phase 1 implementation validates config and CLI framework.');
}

30
src/commands/research.ts Normal file
View File

@@ -0,0 +1,30 @@
import { loadConfig, hasValidKeys } from '../utils/config.js';
import { logger } from '../utils/logger.js';
interface ResearchOptions {
output?: string;
verbose?: boolean;
}
export async function researchCommand(topic: string, options: ResearchOptions): Promise<void> {
const keys = await hasValidKeys();
if (!keys.perplexity) {
logger.error('Perplexity API key not found.');
logger.info('Run `ralph-vibe init` to configure your API keys.');
process.exit(1);
}
const config = await loadConfig();
logger.debug('Config loaded', { hasPerplexityKey: !!config.perplexityApiKey });
if (options.verbose) {
logger.setLevel('debug');
}
logger.info(`Researching: ${topic}`);
// TODO: Implement Perplexity research in Phase 2
logger.info('Research functionality will be implemented in Phase 2.');
logger.info('This command will query Perplexity API for best practices and recommendations.');
}

108
src/commands/validate.ts Normal file
View File

@@ -0,0 +1,108 @@
import { readFileContent, fileExists } from '../utils/files.js';
import { logger } from '../utils/logger.js';
import type { ValidationResult } from '../types/index.js';
export async function validateCommand(path: string): Promise<void> {
if (!await fileExists(path)) {
logger.error(`File not found: ${path}`);
process.exit(1);
}
logger.info(`Validating: ${path}`);
const content = await readFileContent(path);
const result = validatePromptMd(content);
if (result.valid) {
logger.success('PROMPT.md is valid!');
process.exit(0);
} else {
logger.error('Validation failed:');
for (const issue of result.issues) {
const prefix = issue.type === 'error' ? 'ERROR' : 'WARN';
const line = issue.line ? ` (line ${issue.line})` : '';
logger.log(` [${prefix}]${line} ${issue.message}`);
}
if (result.suggestions.length > 0) {
logger.info('\nSuggestions:');
for (const suggestion of result.suggestions) {
logger.log(` - ${suggestion}`);
}
}
process.exit(1);
}
}
export function validatePromptMd(content: string): ValidationResult {
const issues: ValidationResult['issues'] = [];
const suggestions: string[] = [];
// Check for required sections
const requiredSections = [
'Objective',
'Application Type',
'Tech Stack',
'Completion Criteria',
];
for (const section of requiredSections) {
if (!content.includes(`## ${section}`)) {
issues.push({
type: 'error',
message: `Missing required section: ${section}`,
});
}
}
// Check for promise tags
if (!content.includes('<promise>') || !content.includes('</promise>')) {
issues.push({
type: 'warning',
message: 'No promise tags found. Add completion promises for Ralph loop integration.',
});
suggestions.push('Add <promise>PROJECT_COMPLETE</promise> at the end of completion criteria');
}
// Check for ambiguous criteria (words like "should", "might", "could")
const ambiguousPatterns = [
/\bshould\b/gi,
/\bmight\b/gi,
/\bcould\b/gi,
/\bpossibly\b/gi,
/\bmaybe\b/gi,
];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
for (const pattern of ambiguousPatterns) {
if (pattern.test(lines[i])) {
issues.push({
type: 'warning',
message: `Ambiguous language found: "${lines[i].trim().substring(0, 50)}..."`,
line: i + 1,
});
}
}
}
// Check for verification commands in completion criteria
const completionSection = content.match(/## Completion Criteria[\s\S]*?(?=##|$)/);
if (completionSection) {
const hasCommands = /`[^`]+`/.test(completionSection[0]);
if (!hasCommands) {
issues.push({
type: 'warning',
message: 'Completion criteria may be missing verification commands',
});
suggestions.push('Add specific commands to verify each criterion, e.g., `npm run test`');
}
}
return {
valid: issues.filter(i => i.type === 'error').length === 0,
issues,
suggestions,
};
}

91
src/index.ts Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { initCommand } from './commands/init.js';
import { newCommand } from './commands/new.js';
import { validateCommand } from './commands/validate.js';
import { researchCommand } from './commands/research.js';
import { logger } from './utils/logger.js';
const program = new Command();
program
.name('ralph-vibe')
.description('Generate Ralph Method project scaffolds with AI-powered research')
.version('0.1.0');
program
.command('init')
.description('Configure API keys for Claude and Perplexity')
.option('--claude-key <key>', 'Set Claude API key directly')
.option('--perplexity-key <key>', 'Set Perplexity API key directly')
.option('--reset', 'Clear existing configuration')
.action(async (options) => {
try {
await initCommand(options);
} catch (err) {
const error = err as Error;
logger.error(error.message);
process.exit(1);
}
});
program
.command('new <project-name>')
.description('Create a new Ralph Method project')
.option('--idea-file <path>', 'Read idea from file instead of interactive')
.option('--output-dir <path>', 'Output directory (default: ./<project-name>)')
.option('--skip-research', 'Skip Perplexity research phase')
.option('--skip-confirm', "Don't ask for confirmation at each stage")
.option('--verbose', 'Show detailed progress')
.option('--dry-run', 'Show what would be generated without writing files')
.action(async (projectName, options) => {
try {
await newCommand(projectName, options);
} catch (err) {
const error = err as Error;
logger.error(error.message);
process.exit(1);
}
});
program
.command('validate <path>')
.description('Validate an existing PROMPT.md file')
.action(async (path) => {
try {
await validateCommand(path);
} catch (err) {
const error = err as Error;
logger.error(error.message);
process.exit(1);
}
});
program
.command('research <topic>')
.description('Research a topic using Perplexity')
.option('-o, --output <path>', 'Output results to a file')
.option('--verbose', 'Show detailed progress')
.action(async (topic, options) => {
try {
await researchCommand(topic, options);
} catch (err) {
const error = err as Error;
logger.error(error.message);
process.exit(1);
}
});
// Handle graceful shutdown
process.on('SIGINT', () => {
logger.info('\nInterrupted. Exiting...');
process.exit(0);
});
process.on('SIGTERM', () => {
logger.info('\nTerminated. Exiting...');
process.exit(0);
});
program.parse();

132
src/types/index.ts Normal file
View File

@@ -0,0 +1,132 @@
export interface Config {
claudeApiKey?: string;
perplexityApiKey?: string;
}
export type AppType = 'web' | 'desktop' | 'cli' | 'library' | 'mobile' | 'script' | 'other';
export type InterfaceType =
| 'rest_api'
| 'graphql'
| 'ipc'
| 'module'
| 'cli_args'
| 'file_format'
| 'websocket'
| 'grpc';
export type PersistenceType =
| 'remote_db'
| 'local_db'
| 'file_based'
| 'in_memory'
| 'cloud_storage';
export type DeploymentType =
| 'cloud'
| 'self_hosted'
| 'desktop_installer'
| 'package_registry'
| 'app_store'
| 'none';
export interface Architecture {
appType: AppType;
appTypeReason: string;
interfaces: InterfaceType[];
interfacesReason: string;
persistence: PersistenceType;
persistenceReason: string;
deployment: DeploymentType;
deploymentReason: string;
suggestedTechStack: {
language: string;
framework: string;
reasoning: string;
};
}
export interface TechStack {
language: string;
runtime?: string;
framework?: string;
libraries: string[];
testingFramework: string;
buildTool: string;
}
export interface Feature {
id: string;
name: string;
description: string;
userStory: string;
acceptanceCriteria: string[];
}
export interface DataModel {
name: string;
fields: { name: string; type: string; required: boolean }[];
relationships?: string[];
}
export interface InterfaceContract {
name: string;
type: InterfaceType;
endpoints?: { method: string; path: string; description: string }[];
commands?: { name: string; args: string[]; description: string }[];
}
export interface Specification {
features: Feature[];
dataModels: DataModel[];
interfaces: InterfaceContract[];
techStack: TechStack;
}
export interface SearchResult {
answer: string;
sources: { title: string; url: string }[];
citations: string[];
}
export interface Research {
queries: string[];
results: SearchResult[];
summary: string;
}
export interface PRDJson {
project: string;
version: string;
features: {
id: string;
phase: number;
name: string;
description: string;
priority: number;
passes: boolean;
acceptance: string;
}[];
}
export interface PRDOutput {
promptMd: string;
prdJson: PRDJson;
progressTxt: string;
guideMd: string;
claudeMd: string;
}
export interface ValidationResult {
valid: boolean;
issues: {
type: 'error' | 'warning';
message: string;
line?: number;
}[];
suggestions: string[];
}
export interface LogLevel {
level: 'debug' | 'info' | 'warn' | 'error';
}

92
src/utils/config.ts Normal file
View File

@@ -0,0 +1,92 @@
import { readFile, writeFile, mkdir, chmod, stat } from 'fs/promises';
import { homedir } from 'os';
import { join } from 'path';
import type { Config } from '../types/index.js';
import { logger } from './logger.js';
const CONFIG_DIR = join(homedir(), '.ralph-generator');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
export function getConfigDir(): string {
return CONFIG_DIR;
}
export function getConfigFile(): string {
return CONFIG_FILE;
}
export async function ensureConfigDir(): Promise<void> {
try {
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code !== 'EEXIST') {
throw error;
}
}
}
export async function loadConfig(): Promise<Config> {
// Environment variables take precedence
const envConfig: Config = {
claudeApiKey: process.env.CLAUDE_API_KEY,
perplexityApiKey: process.env.PERPLEXITY_API_KEY,
};
try {
const content = await readFile(CONFIG_FILE, 'utf-8');
const fileConfig = JSON.parse(content) as Config;
// Merge: env vars override file config
return {
claudeApiKey: envConfig.claudeApiKey || fileConfig.claudeApiKey,
perplexityApiKey: envConfig.perplexityApiKey || fileConfig.perplexityApiKey,
};
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ENOENT') {
logger.debug('No config file found, using environment variables only');
return envConfig;
}
throw error;
}
}
export async function saveConfig(config: Config): Promise<void> {
await ensureConfigDir();
const content = JSON.stringify(config, null, 2);
await writeFile(CONFIG_FILE, content, { encoding: 'utf-8', mode: 0o600 });
// Ensure file permissions are correct (user read/write only)
await chmod(CONFIG_FILE, 0o600);
}
export async function clearConfig(): Promise<void> {
try {
await writeFile(CONFIG_FILE, '{}', { encoding: 'utf-8', mode: 0o600 });
logger.info('Configuration cleared');
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code !== 'ENOENT') {
throw error;
}
}
}
export async function configExists(): Promise<boolean> {
try {
await stat(CONFIG_FILE);
return true;
} catch {
return false;
}
}
export async function hasValidKeys(): Promise<{ claude: boolean; perplexity: boolean }> {
const config = await loadConfig();
return {
claude: !!config.claudeApiKey && config.claudeApiKey.length > 0,
perplexity: !!config.perplexityApiKey && config.perplexityApiKey.length > 0,
};
}

52
src/utils/files.ts Normal file
View File

@@ -0,0 +1,52 @@
import { readFile, writeFile, mkdir, stat, rename, unlink } from 'fs/promises';
import { dirname, join } from 'path';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
export async function ensureDir(path: string): Promise<void> {
await mkdir(path, { recursive: true });
}
export async function fileExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
export async function readFileContent(path: string): Promise<string> {
return readFile(path, 'utf-8');
}
// Atomic file write: write to temp, then rename
export async function writeFileAtomic(path: string, content: string): Promise<void> {
const dir = dirname(path);
await ensureDir(dir);
const tempFile = join(tmpdir(), `ralph-${randomUUID()}.tmp`);
try {
await writeFile(tempFile, content, 'utf-8');
await rename(tempFile, path);
} catch (err) {
// Clean up temp file on error
try {
await unlink(tempFile);
} catch {
// Ignore cleanup errors
}
throw err;
}
}
export async function readJsonFile<T>(path: string): Promise<T> {
const content = await readFile(path, 'utf-8');
return JSON.parse(content) as T;
}
export async function writeJsonFile<T>(path: string, data: T): Promise<void> {
const content = JSON.stringify(data, null, 2);
await writeFileAtomic(path, content);
}

67
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,67 @@
import chalk from 'chalk';
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
let currentLevel: LogLevel = 'info';
export function setLogLevel(level: LogLevel): void {
currentLevel = level;
}
export function getLogLevel(): LogLevel {
return currentLevel;
}
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
}
export function debug(message: string, ...args: unknown[]): void {
if (shouldLog('debug')) {
console.log(chalk.gray(`[DEBUG] ${message}`), ...args);
}
}
export function info(message: string, ...args: unknown[]): void {
if (shouldLog('info')) {
console.log(chalk.blue(`[INFO] ${message}`), ...args);
}
}
export function warn(message: string, ...args: unknown[]): void {
if (shouldLog('warn')) {
console.log(chalk.yellow(`[WARN] ${message}`), ...args);
}
}
export function error(message: string, ...args: unknown[]): void {
if (shouldLog('error')) {
console.error(chalk.red(`[ERROR] ${message}`), ...args);
}
}
export function success(message: string, ...args: unknown[]): void {
console.log(chalk.green(message), ...args);
}
export function log(message: string, ...args: unknown[]): void {
console.log(message, ...args);
}
export const logger = {
debug,
info,
warn,
error,
success,
log,
setLevel: setLogLevel,
getLevel: getLogLevel,
};

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

20
tsup.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
splitting: false,
sourcemap: false,
target: 'node20',
platform: 'node',
// Don't bundle dependencies - use them from node_modules at runtime
external: [
'commander',
'inquirer',
'chalk',
'ora',
'@anthropic-ai/sdk',
],
});

12
vitest.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules', 'dist', '**/*.test.ts'],
},
},
});