1
0
mirror of synced 2025-12-19 09:57:42 -05:00

Support Copilot Spaces in ai-tools (#58845)

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Sarah Schneider
2025-12-11 17:37:13 -05:00
committed by GitHub
parent 4fd544a3f5
commit be8157c74c
6 changed files with 581 additions and 249 deletions

View File

@@ -0,0 +1,25 @@
import { execSync } from 'child_process'
/**
* Ensure GitHub token is available, exiting process if not found
*/
export function ensureGitHubToken(): void {
if (!process.env.GITHUB_TOKEN) {
try {
const token = execSync('gh auth token', { encoding: 'utf8' }).trim()
if (token) {
process.env.GITHUB_TOKEN = token
return
}
} catch {
// gh CLI not available or not authenticated
}
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)
}
}

View File

@@ -1,4 +1,6 @@
const modelsCompletionsEndpoint = 'https://models.github.ai/inference/chat/completions' const modelsCompletionsEndpoint = 'https://models.github.ai/inference/chat/completions'
const API_TIMEOUT_MS = 180000 // 3 minutes
const DEFAULT_MODEL = 'openai/gpt-4o'
interface ChatMessage { interface ChatMessage {
role: string role: string
@@ -42,16 +44,16 @@ export async function callModelsApi(
// Set default model if none specified // Set default model if none specified
if (!promptWithContent.model) { if (!promptWithContent.model) {
promptWithContent.model = 'openai/gpt-4o' promptWithContent.model = DEFAULT_MODEL
if (verbose) { if (verbose) {
console.log('⚠️ No model specified, using default: openai/gpt-4o') console.log(`⚠️ No model specified, using default: ${DEFAULT_MODEL}`)
} }
} }
try { try {
// Create an AbortController for timeout handling // Create an AbortController for timeout handling
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 180000) // 3 minutes const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
const startTime = Date.now() const startTime = Date.now()
if (verbose) { if (verbose) {
@@ -123,7 +125,7 @@ export async function callModelsApi(
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
throw new Error('API call timed out after 3 minutes') throw new Error(`API call timed out after ${API_TIMEOUT_MS / 1000} seconds`)
} }
console.error('Error calling GitHub Models REST API:', error.message) console.error('Error calling GitHub Models REST API:', error.message)
} }

View File

@@ -0,0 +1,161 @@
import fs from 'fs'
import path from 'path'
import yaml from 'js-yaml'
import readFrontmatter from '@/frame/lib/read-frontmatter'
import { schema } from '@/frame/lib/frontmatter'
const MAX_DIRECTORY_DEPTH = 20
/**
* Enhanced recursive markdown file finder with symlink, depth, and root path checks
*/
export function findMarkdownFiles(
dir: string,
rootDir: string,
depth: number = 0,
maxDepth: number = MAX_DIRECTORY_DEPTH,
visited: Set<string> = new Set(),
): string[] {
const markdownFiles: string[] = []
let realDir: string
try {
realDir = fs.realpathSync(dir)
} catch {
// If we can't resolve real path, skip this directory
return []
}
// Prevent escaping root directory
if (!realDir.startsWith(rootDir)) {
return []
}
// Prevent symlink loops
if (visited.has(realDir)) {
return []
}
visited.add(realDir)
// Prevent excessive depth
if (depth > maxDepth) {
return []
}
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(realDir, { withFileTypes: true })
} catch {
// If we can't read directory, skip
return []
}
for (const entry of entries) {
const fullPath = path.join(realDir, entry.name)
let realFullPath: string
try {
realFullPath = fs.realpathSync(fullPath)
} catch {
continue
}
// Prevent escaping root directory for files
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
}
interface FrontmatterProperties {
intro?: string
[key: string]: unknown
}
/**
* Function to merge new frontmatter properties into existing file while preserving formatting.
* Uses surgical replacement to only modify the specific field(s) being updated,
* preserving all original YAML formatting for unchanged fields.
*/
export 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 {
// Clean up the AI response - remove markdown code blocks if present
let cleanedYaml = newPropertiesYaml.trim()
cleanedYaml = cleanedYaml.replace(/^```ya?ml\s*\n/i, '')
cleanedYaml = cleanedYaml.replace(/\n```\s*$/i, '')
cleanedYaml = cleanedYaml.trim()
const newProperties = yaml.load(cleanedYaml) as FrontmatterProperties
// Security: Validate against prototype pollution using the official frontmatter schema
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
}),
)
// Split content into lines for surgical replacement
const lines = content.split('\n')
let inFrontmatter = false
let frontmatterEndIndex = -1
// Find frontmatter boundaries
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '---') {
if (!inFrontmatter) {
inFrontmatter = true
} else {
frontmatterEndIndex = i
break
}
}
}
// Replace each field value while preserving everything else
for (const [key, value] of Object.entries(sanitizedProperties)) {
const formattedValue = typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : value
// Find the line with this field
for (let i = 1; i < frontmatterEndIndex; i++) {
const line = lines[i]
if (line.startsWith(`${key}:`)) {
// Simple replacement: keep the field name and spacing, replace the value
const colonIndex = line.indexOf(':')
const leadingSpace = line.substring(colonIndex + 1, colonIndex + 2) // Usually a space
lines[i] = `${key}:${leadingSpace}${formattedValue}`
// Remove any continuation lines (multi-line values)
const j = i + 1
while (j < frontmatterEndIndex && lines[j].startsWith(' ')) {
lines.splice(j, 1)
frontmatterEndIndex--
}
break
}
}
}
return lines.join('\n')
} 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}`)
}
}

View File

@@ -0,0 +1,112 @@
import { fileURLToPath } from 'url'
import fs from 'fs'
import yaml from 'js-yaml'
import path from 'path'
import { callModelsApi } from '@/ai-tools/lib/call-models-api'
export interface PromptMessage {
content: string
role: string
}
export interface PromptData {
messages: PromptMessage[]
model?: string
temperature?: number
max_tokens?: number
}
/**
* Get the prompts directory path
*/
export function getPromptsDir(): string {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
return path.join(__dirname, '../prompts')
}
/**
* Dynamically discover available editor types from prompt files
*/
export function getAvailableEditorTypes(promptDir: string): 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
}
/**
* Get formatted description of available refinement types
*/
export function getRefinementDescriptions(editorTypes: string[]): string {
return editorTypes.join(', ')
}
/**
* Call an editor with the given content and options
*/
export async function callEditor(
editorType: string,
content: string,
promptDir: string,
writeMode: boolean,
verbose = false,
promptContent?: string, // Optional: use this instead of reading from file
): Promise<string> {
let markdownPrompt: string
if (promptContent) {
// Use provided prompt content (e.g., from Copilot Space)
markdownPrompt = promptContent
} else {
// Read from file
const markdownPromptPath = path.join(promptDir, `${editorType}.md`)
if (!fs.existsSync(markdownPromptPath)) {
throw new Error(`Prompt file not found: ${markdownPromptPath}`)
}
markdownPrompt = fs.readFileSync(markdownPromptPath, 'utf8')
}
const promptTemplatePath = path.join(promptDir, 'prompt-template.yml')
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)
}

View File

@@ -0,0 +1,109 @@
/**
* Copilot Space API response types
*/
export interface SpaceResource {
id: number
resource_type: string
copilot_chat_attachment_id: string | null
metadata: {
name: string
text: string
}
}
export interface SpaceData {
id: number
number: number
name: string
description: string
general_instructions: string
resources_attributes: SpaceResource[]
html_url: string
created_at: string
updated_at: string
}
/**
* Parse a Copilot Space URL to extract org and space ID
*/
export function parseSpaceUrl(url: string): { org: string; id: string } {
// Expected format: https://api.github.com/orgs/{org}/copilot-spaces/{id}
const match = url.match(/\/orgs\/([^/]+)\/copilot-spaces\/(\d+)/)
if (!match) {
throw new Error(
`Invalid Copilot Space URL format. Expected: https://api.github.com/orgs/{org}/copilot-spaces/{id}`,
)
}
return {
org: match[1],
id: match[2],
}
}
/**
* Fetch a Copilot Space from the GitHub API
*/
export async function fetchCopilotSpace(spaceUrl: string): Promise<SpaceData> {
const { org, id } = parseSpaceUrl(spaceUrl)
const apiUrl = `https://api.github.com/orgs/${org}/copilot-spaces/${id}`
const response = await fetch(apiUrl, {
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
})
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Copilot Space not found: ${apiUrl}`)
} else if (response.status === 401 || response.status === 403) {
throw new Error(
`Authentication failed. Check your GitHub token has access to Copilot Spaces.`,
)
} else {
throw new Error(`Failed to fetch Copilot Space: ${response.status} ${response.statusText}`)
}
}
return (await response.json()) as SpaceData
}
/**
* Convert a Copilot Space to a markdown prompt file
*/
export function convertSpaceToPrompt(space: SpaceData): string {
const timestamp = new Date().toISOString()
const lines: string[] = []
// Header with metadata
lines.push(`<!-- Generated from Copilot Space: ${space.name} -->`)
lines.push(`<!-- Space ID: ${space.number} | Generated: ${timestamp} -->`)
lines.push(`<!-- Space URL: ${space.html_url} -->`)
lines.push('')
// General instructions
if (space.general_instructions) {
lines.push(space.general_instructions.trim())
lines.push('')
}
// Add each resource as a context section
if (space.resources_attributes && space.resources_attributes.length > 0) {
for (const resource of space.resources_attributes) {
if (resource.resource_type === 'free_text' && resource.metadata) {
lines.push('---')
lines.push('')
lines.push(`# Context: ${resource.metadata.name}`)
lines.push('')
lines.push(resource.metadata.text.trim())
lines.push('')
}
}
}
return lines.join('\n')
}

View File

@@ -1,123 +1,36 @@
import { fileURLToPath } from 'url'
import { Command } from 'commander' import { Command } from 'commander'
import fs from 'fs' import fs from 'fs'
import yaml from 'js-yaml'
import path from 'path' import path from 'path'
import ora from 'ora' import ora from 'ora'
import { execSync } from 'child_process' import { execFileSync } from 'child_process'
import { callModelsApi } from '@/ai-tools/lib/call-models-api'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import readFrontmatter from '@/frame/lib/read-frontmatter' import { findMarkdownFiles, mergeFrontmatterProperties } from '@/ai-tools/lib/file-utils'
import { schema } from '@/frame/lib/frontmatter' import {
getPromptsDir,
getAvailableEditorTypes,
getRefinementDescriptions,
callEditor,
} from '@/ai-tools/lib/prompt-utils'
import { fetchCopilotSpace, convertSpaceToPrompt } from '@/ai-tools/lib/spaces-utils'
import { ensureGitHubToken } from '@/ai-tools/lib/auth-utils'
dotenv.config({ quiet: true }) dotenv.config({ quiet: true })
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const promptDir = getPromptsDir()
const promptDir = path.join(__dirname, '../prompts')
const promptTemplatePath = path.join(promptDir, 'prompt-template.yml')
if (!process.env.GITHUB_TOKEN) { // Ensure GitHub token is available
// Try to find a token via the CLI before throwing an error ensureGitHubToken()
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. const editorTypes = getAvailableEditorTypes(promptDir)
2. Install https://cli.github.com and authenticate via 'gh auth login'.
`)
process.exit(1)
}
}
// Dynamically discover available editor types from prompt files
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()
// Enhanced recursive markdown file finder with symlink, depth, and root path checks
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 {
// If we can't resolve real path, skip this directory
return []
}
// Prevent escaping root directory
if (!realDir.startsWith(rootDir)) {
return []
}
// Prevent symlink loops
if (visited.has(realDir)) {
return []
}
visited.add(realDir)
// Prevent excessive depth
if (depth > maxDepth) {
return []
}
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(realDir, { withFileTypes: true })
} catch {
// If we can't read directory, skip
return []
}
for (const entry of entries) {
const fullPath = path.join(realDir, entry.name)
let realFullPath: string
try {
realFullPath = fs.realpathSync(fullPath)
} catch {
continue
}
// Prevent escaping root directory for files
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 { interface CliOptions {
verbose?: boolean verbose?: boolean
prompt?: string[] prompt?: string[]
refine?: string[] refine?: string[]
files: string[] files?: string[]
write?: boolean write?: boolean
exportSpace?: string
space?: string
output?: string
} }
const program = new Command() const program = new Command()
@@ -130,41 +43,115 @@ program
'-w, --write', '-w, --write',
'Write changes back to the original files (default: output to console only)', 'Write changes back to the original files (default: output to console only)',
) )
.option('-p, --prompt <type...>', `Specify one or more prompt type: ${refinementDescriptions()}`) .option(
'-p, --prompt <type...>',
`Specify one or more prompt type: ${getRefinementDescriptions(editorTypes)}`,
)
.option( .option(
'-r, --refine <type...>', '-r, --refine <type...>',
`(Deprecated: use --prompt) Specify one or more prompt type: ${refinementDescriptions()}`, `(Deprecated: use --prompt) Specify one or more prompt type: ${getRefinementDescriptions(editorTypes)}`,
) )
.requiredOption( .option(
'-f, --files <files...>', '--export-space <url>',
'One or more content file paths in the content directory', 'Export a Copilot Space to a prompt file (format: https://api.github.com/orgs/{org}/copilot-spaces/{id})',
) )
.option(
'--space <url>',
'Use a Copilot Space as prompt source (format: https://api.github.com/orgs/{org}/copilot-spaces/{id})',
)
.option(
'--output <filename>',
'Output filename for exported Space prompt (use with --export-space)',
)
.option('-f, --files <files...>', 'One or more content file paths in the content directory')
.action((options: CliOptions) => { .action((options: CliOptions) => {
;(async () => { ;(async () => {
// Handle export-space workflow (standalone, doesn't process files)
if (options.exportSpace) {
if (!options.output) {
console.error('Error: --export-space requires --output option')
process.exit(1)
}
const spinner = ora('Fetching Copilot Space...').start()
try {
const space = await fetchCopilotSpace(options.exportSpace)
spinner.text = `Converting Space "${space.name}" to prompt format...`
const promptContent = convertSpaceToPrompt(space)
const outputPath = path.join(promptDir, options.output)
fs.writeFileSync(outputPath, promptContent, 'utf8')
spinner.succeed(`Exported Space to: ${outputPath}`)
console.log(`\nSpace: ${space.name}`)
console.log(`Resources: ${space.resources_attributes?.length || 0} items`)
console.log(`\nYou can now use it with: --prompt ${path.basename(options.output, '.md')}`)
return
} catch (error) {
spinner.fail(`Failed to export Space: ${(error as Error).message}`)
process.exit(1)
}
}
// Validate mutually exclusive options
if (options.space && options.prompt) {
console.error('Error: Cannot use both --space and --prompt options')
process.exit(1)
}
// Files are required for processing workflows
if (!options.files || options.files.length === 0) {
console.error('Error: --files option is required (unless using --export-space)')
process.exit(1)
}
const spinner = ora('Starting AI review...').start() const spinner = ora('Starting AI review...').start()
const files = options.files const files = options.files
// Handle both --prompt and --refine options for backwards compatibility let prompts: string[] = []
const prompts = options.prompt || options.refine let promptContent: string | undefined
if (!prompts || prompts.length === 0) { // Handle Space workflow (in-memory)
spinner.fail('No prompt type specified. Use --prompt or --refine with one or more types.') if (options.space) {
process.exitCode = 1 try {
return spinner.text = 'Fetching Copilot Space...'
} const space = await fetchCopilotSpace(options.space)
promptContent = convertSpaceToPrompt(space)
prompts = [space.name] // Use space name for display
// Validate that all requested editor types exist if (options.verbose) {
const availableEditors = editorTypes console.log(`Using Space: ${space.name} (ID: ${space.number})`)
for (const editor of prompts) { console.log(`Resources: ${space.resources_attributes?.length || 0} items`)
if (!availableEditors.includes(editor)) { }
spinner.fail( } catch (error) {
`Unknown prompt type: ${editor}. Available types: ${availableEditors.join(', ')}`, spinner.fail(`Failed to fetch Space: ${(error as Error).message}`)
) process.exit(1)
}
} else {
// Handle local prompt workflow
prompts = options.prompt || options.refine || []
if (prompts.length === 0) {
spinner.fail('No prompt type specified. Use --prompt, --refine, or --space.')
process.exitCode = 1 process.exitCode = 1
return return
} }
} }
// Validate local prompt types exist (skip for Space workflow)
if (!options.space) {
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) { if (options.verbose) {
console.log(`Processing ${files.length} files with prompts: ${prompts.join(', ')}`) console.log(`Processing ${files.length} files with prompts: ${prompts.join(', ')}`)
} }
@@ -208,12 +195,20 @@ program
const relativePath = path.relative(process.cwd(), fileToProcess) const relativePath = path.relative(process.cwd(), fileToProcess)
spinner.text = `Processing: ${relativePath}` spinner.text = `Processing: ${relativePath}`
try { try {
// Resolve Liquid references before processing
if (options.verbose) {
console.log(`Resolving Liquid references in: ${relativePath}`)
}
runResolveLiquid('resolve', [fileToProcess], options.verbose || false)
const content = fs.readFileSync(fileToProcess, 'utf8') const content = fs.readFileSync(fileToProcess, 'utf8')
const answer = await callEditor( const answer = await callEditor(
editorType, editorType,
content, content,
promptDir,
options.write || false, options.write || false,
options.verbose || false, options.verbose || false,
promptContent, // Pass Space prompt content if using --space
) )
spinner.stop() spinner.stop()
@@ -235,10 +230,26 @@ program
} }
console.log(answer) console.log(answer)
} }
// Always restore Liquid references after processing (even in non-write mode)
if (options.verbose) {
console.log(`Restoring Liquid references in: ${relativePath}`)
}
runResolveLiquid('restore', [fileToProcess], options.verbose || false)
} catch (err) { } catch (err) {
const error = err as Error const error = err as Error
spinner.fail(`Error processing ${relativePath}: ${error.message}`) spinner.fail(`Error processing ${relativePath}: ${error.message}`)
process.exitCode = 1 process.exitCode = 1
// Still try to restore Liquid references on error
try {
runResolveLiquid('restore', [fileToProcess], false)
} catch (restoreError) {
// Log restore failures in verbose mode for debugging
if (options.verbose) {
console.error(`Warning: Failed to restore Liquid references: ${restoreError}`)
}
}
} finally { } finally {
spinner.stop() spinner.stop()
} }
@@ -263,6 +274,39 @@ program
program.parse(process.argv) program.parse(process.argv)
/**
* Run resolve-liquid command on specified file paths
*/
function runResolveLiquid(
command: 'resolve' | 'restore',
filePaths: string[],
verbose: boolean = false,
): void {
const args = [command, '--paths', ...filePaths]
if (command === 'resolve') {
args.push('--recursive')
}
if (verbose) {
args.push('--verbose')
}
try {
// Run resolve-liquid via tsx
const resolveLiquidPath = path.join(
process.cwd(),
'src/content-render/scripts/resolve-liquid.ts',
)
execFileSync('npx', ['tsx', resolveLiquidPath, ...args], {
stdio: verbose ? 'inherit' : 'pipe',
})
} catch (error) {
if (verbose) {
console.error(`Error running resolve-liquid ${command}:`, error)
}
// Don't fail the entire process if resolve-liquid fails
}
}
// Handle graceful shutdown // Handle graceful shutdown
process.on('SIGINT', () => { process.on('SIGINT', () => {
console.log('\n\n🛑 Process interrupted by user') console.log('\n\n🛑 Process interrupted by user')
@@ -273,124 +317,3 @@ process.on('SIGTERM', () => {
console.log('\n\n🛑 Process terminated') console.log('\n\n🛑 Process terminated')
process.exit(0) process.exit(0)
}) })
interface PromptMessage {
content: string
role: string
}
interface PromptData {
messages: PromptMessage[]
model?: string
temperature?: number
max_tokens?: number
}
// Function to merge new frontmatter properties into existing file while preserving formatting
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 {
// Clean up the AI response - remove markdown code blocks if present
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
// Security: Validate against prototype pollution using the official frontmatter schema
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
}),
)
// Merge new properties with existing frontmatter
const mergedData: FrontmatterProperties = { ...parsed.data, ...sanitizedProperties }
// Manually ensure intro is wrapped in single quotes in the final output
let result = readFrontmatter.stringify(parsed.content, mergedData)
// Post-process to ensure intro field has single quotes
if (newProperties.intro) {
const introValue = newProperties.intro.toString()
// Replace any quote style on intro with single quotes
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)
}