| | import { fileURLToPath } from 'url' |
| | import { Command } from 'commander' |
| | import fs from 'fs' |
| | import yaml from 'js-yaml' |
| | import path from 'path' |
| | import ora from 'ora' |
| | import { execSync } from 'child_process' |
| | import { callModelsApi } from '@/ai-tools/lib/call-models-api' |
| | import dotenv from 'dotenv' |
| | import readFrontmatter from '@/frame/lib/read-frontmatter' |
| | import { schema } from '@/frame/lib/frontmatter' |
| | dotenv.config({ quiet: true }) |
| |
|
| | const __dirname = path.dirname(fileURLToPath(import.meta.url)) |
| | const promptDir = path.join(__dirname, '../prompts') |
| | const promptTemplatePath = path.join(promptDir, 'prompt-template.yml') |
| |
|
| | if (!process.env.GITHUB_TOKEN) { |
| | |
| | const token = execSync('gh auth token').toString() |
| | if (token.startsWith('gh')) { |
| | process.env.GITHUB_TOKEN = token |
| | } else { |
| | console.warn(`🔑 A token is needed to run this script. Please do one of the following and try again: |
| | |
| | 1. Add a GITHUB_TOKEN to a local .env file. |
| | 2. Install https://cli.github.com and authenticate via 'gh auth login'. |
| | `) |
| | process.exit(1) |
| | } |
| | } |
| |
|
| | |
| | const getAvailableEditorTypes = (): string[] => { |
| | const editorTypes: string[] = [] |
| |
|
| | try { |
| | const promptFiles = fs.readdirSync(promptDir) |
| | for (const file of promptFiles) { |
| | if (file.endsWith('.md')) { |
| | const editorName = path.basename(file, '.md') |
| | editorTypes.push(editorName) |
| | } |
| | } |
| | } catch { |
| | console.warn('Could not read prompts directory, using empty editor types') |
| | } |
| |
|
| | return editorTypes |
| | } |
| |
|
| | const editorTypes = getAvailableEditorTypes() |
| |
|
| | |
| | const findMarkdownFiles = ( |
| | dir: string, |
| | rootDir: string, |
| | depth: number = 0, |
| | maxDepth: number = 20, |
| | visited: Set<string> = new Set(), |
| | ): string[] => { |
| | const markdownFiles: string[] = [] |
| | let realDir: string |
| | try { |
| | realDir = fs.realpathSync(dir) |
| | } catch { |
| | |
| | return [] |
| | } |
| | |
| | if (!realDir.startsWith(rootDir)) { |
| | return [] |
| | } |
| | |
| | if (visited.has(realDir)) { |
| | return [] |
| | } |
| | visited.add(realDir) |
| | |
| | if (depth > maxDepth) { |
| | return [] |
| | } |
| | let entries: fs.Dirent[] |
| | try { |
| | entries = fs.readdirSync(realDir, { withFileTypes: true }) |
| | } catch { |
| | |
| | return [] |
| | } |
| | for (const entry of entries) { |
| | const fullPath = path.join(realDir, entry.name) |
| | let realFullPath: string |
| | try { |
| | realFullPath = fs.realpathSync(fullPath) |
| | } catch { |
| | continue |
| | } |
| | |
| | if (!realFullPath.startsWith(rootDir)) { |
| | continue |
| | } |
| | if (entry.isDirectory()) { |
| | markdownFiles.push(...findMarkdownFiles(realFullPath, rootDir, depth + 1, maxDepth, visited)) |
| | } else if (entry.isFile() && entry.name.endsWith('.md')) { |
| | markdownFiles.push(realFullPath) |
| | } |
| | } |
| | return markdownFiles |
| | } |
| |
|
| | const refinementDescriptions = (): string => { |
| | return editorTypes.join(', ') |
| | } |
| |
|
| | interface CliOptions { |
| | verbose?: boolean |
| | prompt?: string[] |
| | refine?: string[] |
| | files: string[] |
| | write?: boolean |
| | } |
| |
|
| | const program = new Command() |
| |
|
| | program |
| | .name('ai-tools') |
| | .description('AI-powered content tools for editing and analysis') |
| | .option('-v, --verbose', 'Enable verbose output') |
| | .option( |
| | '-w, --write', |
| | 'Write changes back to the original files (default: output to console only)', |
| | ) |
| | .option('-p, --prompt <type...>', `Specify one or more prompt type: ${refinementDescriptions()}`) |
| | .option( |
| | '-r, --refine <type...>', |
| | `(Deprecated: use --prompt) Specify one or more prompt type: ${refinementDescriptions()}`, |
| | ) |
| | .requiredOption( |
| | '-f, --files <files...>', |
| | 'One or more content file paths in the content directory', |
| | ) |
| | .action((options: CliOptions) => { |
| | ;(async () => { |
| | const spinner = ora('Starting AI review...').start() |
| |
|
| | const files = options.files |
| | |
| | const prompts = options.prompt || options.refine |
| |
|
| | if (!prompts || prompts.length === 0) { |
| | spinner.fail('No prompt type specified. Use --prompt or --refine with one or more types.') |
| | process.exitCode = 1 |
| | return |
| | } |
| |
|
| | |
| | const availableEditors = editorTypes |
| | for (const editor of prompts) { |
| | if (!availableEditors.includes(editor)) { |
| | spinner.fail( |
| | `Unknown prompt type: ${editor}. Available types: ${availableEditors.join(', ')}`, |
| | ) |
| | process.exitCode = 1 |
| | return |
| | } |
| | } |
| |
|
| | if (options.verbose) { |
| | console.log(`Processing ${files.length} files with prompts: ${prompts.join(', ')}`) |
| | } |
| |
|
| | for (const file of files) { |
| | const filePath = path.resolve(process.cwd(), file) |
| | spinner.text = `Checking file: ${file}` |
| |
|
| | if (!fs.existsSync(filePath)) { |
| | spinner.fail(`File not found: ${filePath}`) |
| | process.exitCode = 1 |
| | continue |
| | } |
| |
|
| | |
| | const isDirectory = fs.statSync(filePath).isDirectory() |
| |
|
| | for (const editorType of prompts) { |
| | try { |
| | |
| | const filesToProcess: string[] = [] |
| |
|
| | if (isDirectory) { |
| | |
| | |
| | const rootDir = fs.realpathSync(process.cwd()) |
| | filesToProcess.push(...findMarkdownFiles(filePath, rootDir)) |
| |
|
| | if (filesToProcess.length === 0) { |
| | spinner.warn(`No markdown files found in directory: ${file}`) |
| | continue |
| | } |
| |
|
| | spinner.text = `Found ${filesToProcess.length} markdown files in ${file}` |
| | } else { |
| | filesToProcess.push(filePath) |
| | } |
| |
|
| | spinner.start() |
| | for (const fileToProcess of filesToProcess) { |
| | const relativePath = path.relative(process.cwd(), fileToProcess) |
| | spinner.text = `Processing: ${relativePath}` |
| | try { |
| | const content = fs.readFileSync(fileToProcess, 'utf8') |
| | const answer = await callEditor( |
| | editorType, |
| | content, |
| | options.write || false, |
| | options.verbose || false, |
| | ) |
| | spinner.stop() |
| |
|
| | if (options.write) { |
| | if (editorType === 'intro') { |
| | |
| | const updatedContent = mergeFrontmatterProperties(fileToProcess, answer) |
| | fs.writeFileSync(fileToProcess, updatedContent, 'utf8') |
| | console.log(`✅ Added frontmatter properties to: ${relativePath}`) |
| | } else { |
| | |
| | fs.writeFileSync(fileToProcess, answer, 'utf8') |
| | console.log(`✅ Updated: ${relativePath}`) |
| | } |
| | } else { |
| | |
| | if (filesToProcess.length > 1) { |
| | console.log(`\n=== ${relativePath} ===`) |
| | } |
| | console.log(answer) |
| | } |
| | } catch (err) { |
| | const error = err as Error |
| | spinner.fail(`Error processing ${relativePath}: ${error.message}`) |
| | process.exitCode = 1 |
| | } finally { |
| | spinner.stop() |
| | } |
| | } |
| | } catch (err) { |
| | const error = err as Error |
| | const targetName = path.relative(process.cwd(), filePath) |
| | spinner.fail(`Error processing ${targetName}: ${error.message}`) |
| | process.exitCode = 1 |
| | } |
| | } |
| | } |
| |
|
| | spinner.stop() |
| |
|
| | |
| | if (process.exitCode) { |
| | process.exit(process.exitCode) |
| | } |
| | })() |
| | }) |
| |
|
| | program.parse(process.argv) |
| |
|
| | |
| | process.on('SIGINT', () => { |
| | console.log('\n\n🛑 Process interrupted by user') |
| | process.exit(0) |
| | }) |
| |
|
| | process.on('SIGTERM', () => { |
| | console.log('\n\n🛑 Process terminated') |
| | process.exit(0) |
| | }) |
| |
|
| | interface PromptMessage { |
| | content: string |
| | role: string |
| | } |
| |
|
| | interface PromptData { |
| | messages: PromptMessage[] |
| | model?: string |
| | temperature?: number |
| | max_tokens?: number |
| | } |
| |
|
| | |
| | function mergeFrontmatterProperties(filePath: string, newPropertiesYaml: string): string { |
| | const content = fs.readFileSync(filePath, 'utf8') |
| | const parsed = readFrontmatter(content) |
| |
|
| | if (parsed.errors && parsed.errors.length > 0) { |
| | throw new Error( |
| | `Failed to parse frontmatter: ${parsed.errors.map((e) => e.message).join(', ')}`, |
| | ) |
| | } |
| |
|
| | if (!parsed.content) { |
| | throw new Error('Failed to parse content from file') |
| | } |
| |
|
| | try { |
| | |
| | let cleanedYaml = newPropertiesYaml.trim() |
| | cleanedYaml = cleanedYaml.replace(/^```ya?ml\s*\n/i, '') |
| | cleanedYaml = cleanedYaml.replace(/\n```\s*$/i, '') |
| | cleanedYaml = cleanedYaml.trim() |
| |
|
| | interface FrontmatterProperties { |
| | intro?: string |
| | [key: string]: unknown |
| | } |
| | const newProperties = yaml.load(cleanedYaml) as FrontmatterProperties |
| |
|
| | |
| | const allowedKeys = Object.keys(schema.properties) |
| |
|
| | const sanitizedProperties = Object.fromEntries( |
| | Object.entries(newProperties).filter(([key]) => { |
| | if (allowedKeys.includes(key)) { |
| | return true |
| | } |
| | console.warn(`Filtered out potentially unsafe frontmatter key: ${key}`) |
| | return false |
| | }), |
| | ) |
| |
|
| | |
| | const mergedData: FrontmatterProperties = { ...parsed.data, ...sanitizedProperties } |
| |
|
| | |
| | let result = readFrontmatter.stringify(parsed.content, mergedData) |
| |
|
| | |
| | if (newProperties.intro) { |
| | const introValue = newProperties.intro.toString() |
| | |
| | result = result.replace( |
| | /^intro:\s*(['"`]?)([^'"`\n\r]+)\1?\s*$/m, |
| | `intro: '${introValue.replace(/'/g, "''")}'`, // Escape single quotes by doubling them |
| | ) |
| | } |
| | return result |
| | } catch (error) { |
| | console.error('Failed to parse AI response as YAML:') |
| | console.error('Raw AI response:', JSON.stringify(newPropertiesYaml)) |
| | throw new Error(`Failed to parse new frontmatter properties: ${error}`) |
| | } |
| | } |
| | |
| | async function callEditor( |
| | editorType: string, |
| | content: string, |
| | writeMode: boolean, |
| | verbose = false, |
| | ): Promise<string> { |
| | const markdownPromptPath = path.join(promptDir, `${String(editorType)}.md`) |
| | |
| | if (!fs.existsSync(markdownPromptPath)) { |
| | throw new Error(`Prompt file not found: ${markdownPromptPath}`) |
| | } |
| | |
| | const markdownPrompt = fs.readFileSync(markdownPromptPath, 'utf8') |
| | |
| | const prompt = yaml.load(fs.readFileSync(promptTemplatePath, 'utf8')) as PromptData |
| | |
| | // Validate the prompt template has required properties |
| | if (!prompt.messages || !Array.isArray(prompt.messages)) { |
| | throw new Error('Invalid prompt template: missing or invalid messages array') |
| | } |
| | |
| | for (const msg of prompt.messages) { |
| | msg.content = msg.content.replace('{{markdownPrompt}}', markdownPrompt) |
| | msg.content = msg.content.replace('{{input}}', content) |
| | // Replace writeMode template variable with simple string replacement |
| | msg.content = msg.content.replace( |
| | /<!-- IF_WRITE_MODE -->/g, |
| | writeMode ? '' : '<!-- REMOVE_START -->', |
| | ) |
| | msg.content = msg.content.replace( |
| | /<!-- ELSE_WRITE_MODE -->/g, |
| | writeMode ? '<!-- REMOVE_START -->' : '', |
| | ) |
| | msg.content = msg.content.replace( |
| | /<!-- END_WRITE_MODE -->/g, |
| | writeMode ? '' : '<!-- REMOVE_END -->', |
| | ) |
| | |
| | // Remove sections marked for removal |
| | msg.content = msg.content.replace(/<!-- REMOVE_START -->[\s\S]*?<!-- REMOVE_END -->/g, '') |
| | } |
| | |
| | return callModelsApi(prompt, verbose) |
| | } |
| | |