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:
37
.claude/ralph-loop.local.md
Normal file
37
.claude/ralph-loop.local.md
Normal 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
15
eslint.config.js
Normal 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
3939
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
prd.json
4
prd.json
@@ -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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
14
progress.txt
14
progress.txt
@@ -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
|
||||||
|
|
||||||
|
|||||||
149
src/__tests__/config.test.ts
Normal file
149
src/__tests__/config.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
87
src/__tests__/files.test.ts
Normal file
87
src/__tests__/files.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
93
src/__tests__/logger.test.ts
Normal file
93
src/__tests__/logger.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
107
src/__tests__/validate.test.ts
Normal file
107
src/__tests__/validate.test.ts
Normal 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
115
src/commands/init.ts
Normal 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
47
src/commands/new.ts
Normal 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
30
src/commands/research.ts
Normal 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
108
src/commands/validate.ts
Normal 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
91
src/index.ts
Normal 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
132
src/types/index.ts
Normal 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
92
src/utils/config.ts
Normal 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
52
src/utils/files.ts
Normal 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
67
src/utils/logger.ts
Normal 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
24
tsconfig.json
Normal 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
20
tsup.config.ts
Normal 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
12
vitest.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user