diff --git a/src/commands/init.ts b/src/commands/init.ts index 1a3fcad..17d15dc 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; -import { existsSync } from "node:fs"; +import { existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import chalk from "chalk"; @@ -37,8 +37,8 @@ export const initCommand = new Command("init") const candidates = ["python3", "python"]; for (const name of candidates) { try { - const { execSync } = await import("node:child_process"); - const version = execSync(`${name} --version`, { + const { execFileSync } = await import("node:child_process"); + const version = execFileSync(name, ["--version"], { encoding: "utf-8", }).trim(); const match = version.match(/Python (\d+\.\d+)/); @@ -126,6 +126,12 @@ export const initCommand = new Command("init") saveConfig(config); + // Create patterns directory for user-defined patterns + const patternsDir = join(homedir(), ".ace", "patterns"); + if (!existsSync(patternsDir)) { + mkdirSync(patternsDir, { recursive: true }); + } + // Step 5: Summary console.log(chalk.bold("\nSetup complete:")); console.log( @@ -153,7 +159,7 @@ export const initCommand = new Command("init") "\n" ); - console.log(chalk.dim("Try: ace workflow list-templates")); + console.log(chalk.dim("Try: ace run --list")); rl.close(); }); diff --git a/src/commands/run.ts b/src/commands/run.ts new file mode 100644 index 0000000..3d9a0f1 --- /dev/null +++ b/src/commands/run.ts @@ -0,0 +1,180 @@ +import { Command } from "commander"; +import chalk from "chalk"; +import ora from "ora"; +import { ensurePython } from "../utils/ensure-python.js"; +import { + listPatterns, + loadPattern, + readStdin, + readInputFile, + runPattern, + runBatch, + writeOutput, + getUserPatternsDir, +} from "../utils/patterns.js"; +import * as output from "../utils/output.js"; + +export const runCommand = new Command("run") + .description("Run a pattern on text input (pipe-friendly)") + .argument("[pattern]", "Pattern name to run") + .argument("[text]", "Inline text input") + .option("-l, --list", "List available patterns") + .option("--info ", "Show pattern details") + .option("-m, --model ", "Override the LLM model") + .option("-j, --json", "Output as JSON") + .option("-v, --verbose", "Show debug output") + .option("-f, --file ", "Read input from a file") + .option("--input-dir ", "Process all files in a directory") + .option("-o, --output ", "Write output to a file") + .option("--output-dir ", "Write batch outputs to a directory") + .action( + async ( + patternName: string | undefined, + inlineText: string | undefined, + options: { + list?: boolean; + info?: string; + model?: string; + json?: boolean; + verbose?: boolean; + file?: string; + inputDir?: string; + output?: string; + outputDir?: string; + } + ) => { + // ── List patterns ────────────────────────────────── + if (options.list) { + const patterns = listPatterns(); + const grouped = new Map(); + for (const p of patterns) { + const group = grouped.get(p.category) || []; + group.push(p); + grouped.set(p.category, group); + } + + for (const [category, categoryPatterns] of grouped) { + console.log(chalk.bold(`\n${category.charAt(0).toUpperCase() + category.slice(1)}`)); + for (const p of categoryPatterns) { + console.log(` ${chalk.cyan(p.id.padEnd(22))} ${p.description}`); + } + } + + console.log(chalk.dim(`\nUser patterns: ${getUserPatternsDir()}`)); + return; + } + + // ── Pattern info ─────────────────────────────────── + if (options.info) { + const pattern = loadPattern(options.info); + if (!pattern) { + output.error(`Pattern not found: ${options.info}`); + const all = listPatterns(); + console.log(chalk.dim(`Available: ${all.map((p) => p.id).join(", ")}`)); + process.exit(1); + } + + console.log(chalk.bold(pattern.name)); + console.log(chalk.dim(pattern.description)); + console.log(); + console.log(chalk.bold("Category:"), pattern.category); + if (pattern.model) { + console.log(chalk.bold("Model:"), pattern.model); + } + console.log(); + console.log(chalk.bold("System Prompt:")); + console.log(chalk.dim("─".repeat(60))); + console.log(pattern.systemPrompt); + console.log(chalk.dim("─".repeat(60))); + return; + } + + // ── Validate pattern name ────────────────────────── + if (!patternName) { + output.error("Pattern name required. Use --list to see available patterns."); + console.log(chalk.dim("Usage: ace run [text]")); + console.log(chalk.dim(" echo \"text\" | ace run ")); + process.exit(1); + } + + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(patternName)) { + output.error("Invalid pattern name. Use only letters, numbers, hyphens, and underscores."); + process.exit(1); + } + + const pattern = loadPattern(patternName); + if (!pattern) { + output.error(`Pattern not found: ${patternName}`); + const all = listPatterns(); + console.log(chalk.dim(`Available: ${all.map((p) => p.id).join(", ")}`)); + process.exit(1); + } + + // ── Ensure Python + aceteam-nodes ────────────────── + const pythonPath = await ensurePython(); + + // ── Batch mode (folder → folder) ────────────────── + if (options.inputDir) { + if (!options.outputDir) { + output.error("--output-dir is required with --input-dir"); + process.exit(1); + } + + await runBatch(pythonPath, pattern, options.inputDir, { + outputDir: options.outputDir, + model: options.model, + json: options.json, + verbose: options.verbose, + }); + return; + } + + // ── Resolve input text ───────────────────────────── + // Priority: --file > inline text arg > stdin pipe + let inputText: string; + + if (options.file) { + try { + inputText = readInputFile(options.file); + } catch (err) { + output.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + } else if (inlineText) { + inputText = inlineText; + } else { + inputText = await readStdin(); + } + + if (!inputText) { + output.error("No input provided."); + console.log(chalk.dim("Usage: ace run \"text to process\"")); + console.log(chalk.dim(" ace run --file input.txt")); + console.log(chalk.dim(" echo \"text\" | ace run ")); + process.exit(1); + } + + // ── Execute pattern via aceteam-nodes ────────────── + const spinner = ora(`Running ${pattern.name}...`).start(); + + try { + const result = await runPattern(pythonPath, pattern, inputText, { + model: options.model, + json: options.json, + verbose: options.verbose, + }); + + spinner.stop(); + writeOutput(result, options.output); + + if (options.output) { + output.success(`Output written to ${options.output}`); + } + } catch (err) { + spinner.fail(`${pattern.name} failed`); + const message = err instanceof Error ? err.message : String(err); + console.error(chalk.red(message)); + process.exit(1); + } + } + ); diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts index 95dca8b..adc2406 100644 --- a/src/commands/workflow.ts +++ b/src/commands/workflow.ts @@ -5,64 +5,16 @@ import { stdin, stdout } from "node:process"; import chalk from "chalk"; import ora from "ora"; import { - findPython, - isAceteamNodesInstalled, - installAceteamNodes, runWorkflow, validateWorkflow, listNodes, - getVenvPythonPath, - isVenvValid, } from "../utils/python.js"; import { loadConfig } from "../utils/config.js"; import { FabricClient } from "../utils/fabric.js"; import { classifyPythonError } from "../utils/errors.js"; import { TEMPLATES, getTemplateById } from "../templates/index.js"; import * as output from "../utils/output.js"; - -async function ensurePython(): Promise { - const config = loadConfig(); - - // Check config python_path first (managed venv) - if (config.python_path && existsSync(config.python_path)) { - if (isAceteamNodesInstalled(config.python_path)) { - return config.python_path; - } - } - - // Check managed venv - if (config.venv_dir && isVenvValid(config.venv_dir)) { - const venvPython = getVenvPythonPath(config.venv_dir); - if (isAceteamNodesInstalled(venvPython)) { - return venvPython; - } - } - - // Fallback to PATH detection - const pythonPath = await findPython(); - if (!pythonPath) { - output.error( - "Python 3.12+ not found. Please install Python and run: ace init" - ); - process.exit(1); - } - - if (!isAceteamNodesInstalled(pythonPath)) { - output.warn("aceteam-nodes is not installed."); - console.log("Installing aceteam-nodes..."); - try { - installAceteamNodes(pythonPath); - output.success("aceteam-nodes installed"); - } catch { - output.error( - "Failed to install aceteam-nodes. Try: pip install aceteam-nodes" - ); - process.exit(1); - } - } - - return pythonPath; -} +import { ensurePython } from "../utils/ensure-python.js"; function parseInputArgs(inputs: string[]): Record { const result: Record = {}; diff --git a/src/index.ts b/src/index.ts index 3b1fc42..4b3befe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { initCommand } from "./commands/init.js"; import { workflowCommand } from "./commands/workflow.js"; import { fabricCommand } from "./commands/fabric.js"; +import { runCommand } from "./commands/run.js"; const program = new Command(); @@ -11,6 +12,7 @@ program .version("0.2.0"); program.addCommand(initCommand); +program.addCommand(runCommand); program.addCommand(workflowCommand); program.addCommand(fabricCommand); diff --git a/src/patterns/index.ts b/src/patterns/index.ts new file mode 100644 index 0000000..7f96c91 --- /dev/null +++ b/src/patterns/index.ts @@ -0,0 +1,290 @@ +export interface PatternDef { + id: string; + name: string; + description: string; + category: string; + systemPrompt: string; + model?: string; + temperature?: number; +} + +export const BUILTIN_PATTERNS: PatternDef[] = [ + // ── General ────────────────────────────────────────────── + { + id: "summarize", + name: "Summarize", + description: "Structured summary with main points and conclusion", + category: "general", + systemPrompt: `You are an expert summarizer. Given the input text, produce a clear and structured summary. + +Your output MUST follow this format: + +## Summary +A 2-3 sentence overview of the content. + +## Key Points +- Point 1 +- Point 2 +- Point 3 +(include all important points) + +## Conclusion +A 1-2 sentence takeaway or conclusion from the material. + +Be concise but thorough. Preserve important details, numbers, and names. Do not add information that is not present in the source text.`, + }, + { + id: "extract-data", + name: "Extract Data", + description: "Extract entities, dates, numbers into structured format", + category: "general", + systemPrompt: `You are a data extraction specialist. Analyze the input text and extract all structured data. + +Your output MUST follow this format: + +## Entities +- **People**: [list of names] +- **Organizations**: [list of org names] +- **Locations**: [list of places] + +## Dates & Times +- [date/time references with context] + +## Numbers & Metrics +- [numerical data with labels and context] + +## Key Facts +- [important factual statements] + +Extract everything precisely as stated in the source. Do not infer or add data not present in the text. Use "None found" for empty categories.`, + }, + { + id: "translate", + name: "Translate", + description: "Translate text to English (or specify target language)", + category: "general", + systemPrompt: `You are a professional translator. Translate the input text to English. + +Guidelines: +- Preserve the original meaning, tone, and intent +- Maintain formatting (paragraphs, lists, headers) +- For technical terms, provide the translation with the original in parentheses on first use +- If the text is already in English, respond with the text as-is and note "Text is already in English" +- If a target language is specified in the input (e.g., "translate to Spanish:"), translate to that language instead + +Output only the translated text, with no additional commentary.`, + }, + { + id: "improve-writing", + name: "Improve Writing", + description: "Improve clarity, grammar, and style", + category: "general", + systemPrompt: `You are a professional editor. Improve the input text for clarity, grammar, and readability. + +Guidelines: +- Fix grammatical errors and typos +- Improve sentence structure and flow +- Maintain the author's voice and intent +- Keep technical terminology accurate +- Preserve the original meaning — do not add new ideas + +Output the improved text first, then add a brief "## Changes Made" section listing the key improvements.`, + }, + { + id: "explain", + name: "Explain", + description: "Explain complex content in simpler terms", + category: "general", + systemPrompt: `You are an expert educator. Explain the input text in clear, simple language that a non-expert can understand. + +Guidelines: +- Use plain language — avoid jargon or define it when necessary +- Use analogies and examples to illustrate complex concepts +- Break down the explanation into logical steps +- Aim for a high school reading level +- Preserve accuracy while simplifying + +Structure your response with clear sections if the topic warrants it.`, + }, + { + id: "analyze-risk", + name: "Analyze Risk", + description: "Risk assessment with severity levels", + category: "general", + systemPrompt: `You are a risk analysis expert. Analyze the input text and identify potential risks. + +Your output MUST follow this format: + +## Risk Assessment + +### High Severity +- **[Risk Name]**: [Description] | Impact: [description] | Likelihood: [High/Medium/Low] | Mitigation: [suggested action] + +### Medium Severity +- **[Risk Name]**: [Description] | Impact: [description] | Likelihood: [High/Medium/Low] | Mitigation: [suggested action] + +### Low Severity +- **[Risk Name]**: [Description] | Impact: [description] | Likelihood: [High/Medium/Low] | Mitigation: [suggested action] + +## Overall Assessment +A 2-3 sentence summary of the overall risk profile and top recommendations. + +Base your analysis only on information present in the input. Use "No risks identified" for empty severity levels.`, + }, + { + id: "create-outline", + name: "Create Outline", + description: "Structured outline with headers and sub-points", + category: "general", + systemPrompt: `You are an expert content organizer. Create a clear, hierarchical outline from the input text. + +Your output MUST use this format: + +# [Title] + +## I. [First Major Section] + A. [Sub-point] + 1. [Detail] + 2. [Detail] + B. [Sub-point] + +## II. [Second Major Section] + A. [Sub-point] + B. [Sub-point] + +(continue as needed) + +Guidelines: +- Organize content logically by theme or chronology +- Use consistent hierarchy (Roman numerals → letters → numbers) +- Include all important points from the source material +- Keep outline entries concise but descriptive`, + }, + + // ── Government ─────────────────────────────────────────── + { + id: "department-scanner", + name: "Department Scanner", + description: "Department performance analysis with metrics, gaps, and recommendations", + category: "government", + systemPrompt: `You are a municipal performance analyst specializing in city government operations. Analyze the department information provided and produce a comprehensive performance report. + +Your output MUST follow this format: + +## Department Overview +- **Department**: [name] +- **Primary Mission**: [1-2 sentence summary] +- **Staff Size**: [if mentioned] +- **Budget**: [if mentioned] + +## Key Performance Metrics +| Metric | Value | Trend | Assessment | +|--------|-------|-------|------------| +| [metric name] | [value] | [up/down/stable] | [good/needs attention/critical] | + +(include all quantitative metrics found in the input) + +## Strengths +- [strength 1] +- [strength 2] + +## Gaps & Concerns +- **[Gap]**: [description and impact] +- **[Gap]**: [description and impact] + +## Recommendations +1. **[Short-term]**: [actionable recommendation] +2. **[Medium-term]**: [actionable recommendation] +3. **[Long-term]**: [actionable recommendation] + +## Risk Flags +- [any urgent issues that need immediate attention] + +Base your analysis strictly on the provided data. Flag areas where data is insufficient for a complete assessment.`, + }, + { + id: "grant-writer", + name: "Grant Writer", + description: "Transform rough notes into grant application content", + category: "government", + systemPrompt: `You are an experienced grant writer for municipal and government organizations. Transform the input notes into polished grant application content. + +Your output MUST follow this format: + +## Project Narrative + +### Need Statement +[2-3 paragraphs establishing the need, supported by data from the input] + +### Project Description +[2-3 paragraphs describing the proposed project, activities, and timeline] + +### Goals & Objectives +1. **Goal**: [broad goal] + - Objective: [specific, measurable objective] + - Objective: [specific, measurable objective] + +### Expected Outcomes +- [measurable outcome 1] +- [measurable outcome 2] + +### Sustainability Plan +[1-2 paragraphs on how the project will sustain beyond the grant period] + +## Budget Justification +[If budget data is provided, create line-item justifications] + +Guidelines: +- Use formal, professional grant writing tone +- Quantify impact wherever possible +- Align language with typical government grant requirements +- Flag any missing information needed for a complete application with [NEEDS: description]`, + }, + { + id: "document-summarizer", + name: "Document Summarizer", + description: "Policy and budget document briefing for city officials", + category: "government", + systemPrompt: `You are a senior policy analyst preparing executive briefings for city officials. Summarize the input document into a concise briefing format suitable for busy decision-makers. + +Your output MUST follow this format: + +## Executive Briefing + +**Document**: [title/type of document] +**Date**: [if mentioned] +**Prepared for**: City Leadership + +### Bottom Line Up Front (BLUF) +[2-3 sentences capturing the most critical takeaway — what the reader must know] + +### Key Facts +- [fact 1] +- [fact 2] +- [fact 3] + +### Financial Impact +- **Total Cost/Budget**: [amount if mentioned] +- **Key Line Items**: [significant budget items] +- **Funding Source**: [if mentioned] + +### Action Items / Decisions Required +1. [action needed with deadline if mentioned] +2. [action needed] + +### Stakeholder Impact +- **Residents**: [how this affects residents] +- **Departments**: [departmental impact] +- **Timeline**: [key dates and milestones] + +### Risks & Considerations +- [risk or consideration 1] +- [risk or consideration 2] + +Keep the briefing under 500 words. Use plain language — avoid jargon. Highlight numbers and deadlines prominently.`, + }, +]; + +export function getPatternById(id: string): PatternDef | undefined { + return BUILTIN_PATTERNS.find((p) => p.id === id); +} diff --git a/src/utils/config.ts b/src/utils/config.ts index f0661f5..a9093d7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -12,6 +12,7 @@ export interface AceConfig { fabric_api_key?: string; python_path?: string; venv_dir?: string; + patterns_dir?: string; } export function getConfigPath(): string { diff --git a/src/utils/ensure-python.ts b/src/utils/ensure-python.ts new file mode 100644 index 0000000..0554c9a --- /dev/null +++ b/src/utils/ensure-python.ts @@ -0,0 +1,54 @@ +import { existsSync } from "node:fs"; +import { + findPython, + isAceteamNodesInstalled, + installAceteamNodes, + getVenvPythonPath, + isVenvValid, +} from "./python.js"; +import { loadConfig } from "./config.js"; +import * as output from "./output.js"; + +export async function ensurePython(): Promise { + const config = loadConfig(); + + // Check config python_path first (managed venv) + if (config.python_path && existsSync(config.python_path)) { + if (isAceteamNodesInstalled(config.python_path)) { + return config.python_path; + } + } + + // Check managed venv + if (config.venv_dir && isVenvValid(config.venv_dir)) { + const venvPython = getVenvPythonPath(config.venv_dir); + if (isAceteamNodesInstalled(venvPython)) { + return venvPython; + } + } + + // Fallback to PATH detection + const pythonPath = await findPython(); + if (!pythonPath) { + output.error( + "Python 3.12+ not found. Please install Python and run: ace init" + ); + process.exit(1); + } + + if (!isAceteamNodesInstalled(pythonPath)) { + output.warn("aceteam-nodes is not installed."); + console.log("Installing aceteam-nodes..."); + try { + installAceteamNodes(pythonPath); + output.success("aceteam-nodes installed"); + } catch { + output.error( + "Failed to install aceteam-nodes. Try: pip install aceteam-nodes" + ); + process.exit(1); + } + } + + return pythonPath; +} diff --git a/src/utils/patterns.ts b/src/utils/patterns.ts new file mode 100644 index 0000000..c1e369d --- /dev/null +++ b/src/utils/patterns.ts @@ -0,0 +1,316 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, extname, join } from "node:path"; +import { homedir } from "node:os"; +import { tmpdir } from "node:os"; +import ora from "ora"; +import chalk from "chalk"; +import { BUILTIN_PATTERNS, type PatternDef } from "../patterns/index.js"; +import { loadConfig } from "./config.js"; +import { runWorkflow, type RunResult } from "./python.js"; +import { classifyPythonError } from "./errors.js"; +import * as output from "./output.js"; + +const USER_PATTERNS_DIR = join(homedir(), ".ace", "patterns"); +const SUPPORTED_EXTENSIONS = new Set([".txt", ".md", ".csv", ".json"]); + +// ── Pattern Loading ──────────────────────────────────────── + +export function getUserPatternsDir(): string { + return USER_PATTERNS_DIR; +} + +const PATTERN_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; + +function loadUserPattern(name: string): PatternDef | undefined { + if (!PATTERN_NAME_REGEX.test(name)) { + return undefined; + } + + const patternDir = join(USER_PATTERNS_DIR, name); + const systemFile = join(patternDir, "system.md"); + + if (!existsSync(systemFile)) { + return undefined; + } + + const systemPrompt = readFileSync(systemFile, "utf-8").trim(); + return { + id: name, + name: name.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), + description: `User pattern: ${name}`, + category: "user", + systemPrompt, + }; +} + +function listUserPatterns(): PatternDef[] { + if (!existsSync(USER_PATTERNS_DIR)) { + return []; + } + + const entries = readdirSync(USER_PATTERNS_DIR, { withFileTypes: true }); + const patterns: PatternDef[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + const pattern = loadUserPattern(entry.name); + if (pattern) { + patterns.push(pattern); + } + } + } + + return patterns; +} + +export function listPatterns(): PatternDef[] { + const userPatterns = listUserPatterns(); + const userIds = new Set(userPatterns.map((p) => p.id)); + + // User patterns override built-ins with the same name + const builtins = BUILTIN_PATTERNS.filter((p) => !userIds.has(p.id)); + return [...builtins, ...userPatterns]; +} + +export function loadPattern(name: string): PatternDef | undefined { + // User patterns take priority over built-ins + const userPattern = loadUserPattern(name); + if (userPattern) { + return userPattern; + } + + return BUILTIN_PATTERNS.find((p) => p.id === name); +} + +// ── Workflow Generation ──────────────────────────────────── + +export function patternToWorkflow( + pattern: PatternDef, + modelOverride?: string +): Record { + const config = loadConfig(); + const model = modelOverride || pattern.model || config.default_model || "gpt-4o-mini"; + const temperature = String(pattern.temperature ?? 0.7); + + return { + name: pattern.name, + description: pattern.description, + nodes: [ + { + id: "llm", + type: "LLM", + params: { + model, + system_prompt: pattern.systemPrompt, + temperature, + max_tokens: "4096", + }, + position: { x: 400, y: 200 }, + }, + ], + edges: [], + input_edges: [ + { + input_key: "prompt", + target_id: "llm", + target_key: "prompt", + }, + ], + output_edges: [ + { + source_id: "llm", + source_key: "response", + output_key: "response", + }, + ], + inputs: [ + { + name: "prompt", + type: "LONG_TEXT", + display_name: "Prompt", + description: "The text to process", + }, + ], + outputs: [ + { + name: "response", + type: "LONG_TEXT", + display_name: "Response", + description: "The processed output", + }, + ], + }; +} + +// ── I/O Utilities ────────────────────────────────────────── + +export function readStdin(): Promise { + return new Promise((resolve, reject) => { + if (process.stdin.isTTY) { + resolve(""); + return; + } + + let data = ""; + process.stdin.setEncoding("utf-8"); + process.stdin.on("data", (chunk: string) => { + data += chunk; + }); + process.stdin.on("end", () => { + resolve(data.trim()); + }); + process.stdin.on("error", reject); + }); +} + +export function readInputFile(filePath: string): string { + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + const ext = extname(filePath).toLowerCase(); + if (!SUPPORTED_EXTENSIONS.has(ext)) { + throw new Error( + `Unsupported file type: ${ext}. Supported: ${[...SUPPORTED_EXTENSIONS].join(", ")}` + ); + } + + return readFileSync(filePath, "utf-8").trim(); +} + +export function scanInputDir(dirPath: string): string[] { + if (!existsSync(dirPath)) { + throw new Error(`Directory not found: ${dirPath}`); + } + + const entries = readdirSync(dirPath, { withFileTypes: true }); + return entries + .filter( + (e) => e.isFile() && SUPPORTED_EXTENSIONS.has(extname(e.name).toLowerCase()) + ) + .map((e) => join(dirPath, e.name)) + .sort(); +} + +export function writeOutput(text: string, filePath?: string): void { + if (filePath) { + writeFileSync(filePath, text + "\n", "utf-8"); + } else { + process.stdout.write(text + "\n"); + } +} + +// ── Execution ────────────────────────────────────────────── + +export interface RunPatternOptions { + model?: string; + json?: boolean; + verbose?: boolean; +} + +export async function runPattern( + pythonPath: string, + pattern: PatternDef, + inputText: string, + options: RunPatternOptions = {} +): Promise { + const workflow = patternToWorkflow(pattern, options.model); + const tempFile = join(tmpdir(), `ace-pattern-${pattern.id}-${Date.now()}.json`); + + try { + writeFileSync(tempFile, JSON.stringify(workflow, null, 2), "utf-8"); + + const result: RunResult = await runWorkflow( + pythonPath, + tempFile, + { prompt: inputText }, + { verbose: options.verbose } + ); + + if (!result.success) { + const rawError = + result.error || + (result.errors ? JSON.stringify(result.errors) : "Unknown error"); + const classified = classifyPythonError(rawError); + throw new Error(classified.message + (classified.suggestion ? `\n${classified.suggestion}` : "")); + } + + const response = result.output?.response; + if (typeof response !== "string") { + throw new Error("Unexpected output format from workflow"); + } + + if (options.json) { + return JSON.stringify({ pattern: pattern.id, response }, null, 2); + } + + return response; + } finally { + // Clean up temp file (best effort) + try { + const { unlinkSync } = await import("node:fs"); + unlinkSync(tempFile); + } catch { + // Ignore cleanup failures + } + } +} + +export interface BatchOptions extends RunPatternOptions { + outputDir: string; +} + +export async function runBatch( + pythonPath: string, + pattern: PatternDef, + inputDir: string, + options: BatchOptions +): Promise { + const files = scanInputDir(inputDir); + if (files.length === 0) { + output.warn(`No supported files found in ${inputDir}`); + return; + } + + // Ensure output directory exists + if (!existsSync(options.outputDir)) { + mkdirSync(options.outputDir, { recursive: true }); + } + + const spinner = ora(`Processing 0/${files.length}...`).start(); + let processed = 0; + let failed = 0; + + for (const file of files) { + const fileName = basename(file, extname(file)); + const outputFile = join(options.outputDir, `${fileName}.txt`); + + spinner.text = `Processing ${processed + 1}/${files.length}: ${basename(file)}...`; + + try { + const content = readInputFile(file); + const result = await runPattern(pythonPath, pattern, content, { + model: options.model, + verbose: options.verbose, + }); + writeFileSync(outputFile, result + "\n", "utf-8"); + processed++; + } catch (err) { + failed++; + spinner.warn( + `Failed: ${basename(file)} — ${err instanceof Error ? err.message : String(err)}` + ); + spinner.start(`Processing ${processed + failed + 1}/${files.length}...`); + } + } + + if (failed === 0) { + spinner.succeed( + `Processed ${processed} file${processed === 1 ? "" : "s"} ${chalk.dim(`→ ${options.outputDir}`)}` + ); + } else { + spinner.warn( + `Processed ${processed}/${files.length} files (${failed} failed) ${chalk.dim(`→ ${options.outputDir}`)}` + ); + } +} diff --git a/src/utils/python.ts b/src/utils/python.ts index 1079516..d6f106b 100644 --- a/src/utils/python.ts +++ b/src/utils/python.ts @@ -1,4 +1,4 @@ -import { execSync, spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; import which from "which"; @@ -35,7 +35,7 @@ export interface PythonVersion { */ export function getPythonVersion(pythonPath: string): PythonVersion | null { try { - const output = execSync(`${pythonPath} --version`, { + const output = execFileSync(pythonPath, ["--version"], { encoding: "utf-8", }).trim(); const match = output.match(/Python (\d+)\.(\d+)\.(\d+)/); @@ -56,7 +56,7 @@ export function getPythonVersion(pythonPath: string): PythonVersion | null { * Create a Python virtual environment. */ export function createVenv(pythonPath: string, venvDir: string): void { - execSync(`${pythonPath} -m venv ${venvDir}`, { stdio: "pipe" }); + execFileSync(pythonPath, ["-m", "venv", venvDir], { stdio: "pipe" }); } /** @@ -82,7 +82,7 @@ export function isVenvValid(venvDir: string): boolean { */ export function isAceteamNodesInstalled(pythonPath: string): boolean { try { - execSync(`${pythonPath} -c "import aceteam_nodes"`, { + execFileSync(pythonPath, ["-c", "import aceteam_nodes"], { stdio: "pipe", }); return true; @@ -95,7 +95,7 @@ export function isAceteamNodesInstalled(pythonPath: string): boolean { * Install aceteam-nodes via pip. */ export function installAceteamNodes(pythonPath: string): void { - execSync(`${pythonPath} -m pip install aceteam-nodes`, { + execFileSync(pythonPath, ["-m", "pip", "install", "aceteam-nodes"], { stdio: "inherit", }); } diff --git a/tests/commands/run.test.ts b/tests/commands/run.test.ts new file mode 100644 index 0000000..7c4841d --- /dev/null +++ b/tests/commands/run.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; + +// Mock dependencies +vi.mock("../../src/utils/ensure-python.js", () => ({ + ensurePython: vi.fn(() => Promise.resolve("/usr/bin/python3")), +})); + +vi.mock("../../src/utils/python.js", () => ({ + runWorkflow: vi.fn(() => + Promise.resolve({ + success: true, + output: { response: "Mock response" }, + }) + ), +})); + +vi.mock("../../src/utils/errors.js", () => ({ + classifyPythonError: vi.fn((msg: string) => ({ message: msg })), +})); + +vi.mock("ora", () => ({ + default: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + text: "", + })), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ""), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(() => []), + unlinkSync: vi.fn(), + }; +}); + +vi.mock("../../src/utils/config.js", () => ({ + loadConfig: vi.fn(() => ({ default_model: "gpt-4o-mini" })), +})); + +import { runCommand } from "../../src/commands/run.js"; + +describe("runCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("is a Commander command named 'run'", () => { + expect(runCommand).toBeInstanceOf(Command); + expect(runCommand.name()).toBe("run"); + }); + + it("has expected options", () => { + const optionNames = runCommand.options.map((o) => o.long); + expect(optionNames).toContain("--list"); + expect(optionNames).toContain("--info"); + expect(optionNames).toContain("--model"); + expect(optionNames).toContain("--json"); + expect(optionNames).toContain("--verbose"); + expect(optionNames).toContain("--file"); + expect(optionNames).toContain("--input-dir"); + expect(optionNames).toContain("--output"); + expect(optionNames).toContain("--output-dir"); + }); + + it("uses ensurePython for workflow execution", () => { + const commandStr = runCommand.description(); + expect(commandStr).toBeDefined(); + expect(commandStr).toContain("pattern"); + }); +}); diff --git a/tests/integration/init-lifecycle.test.ts b/tests/integration/init-lifecycle.test.ts index cf1e0ce..3e09bc3 100644 --- a/tests/integration/init-lifecycle.test.ts +++ b/tests/integration/init-lifecycle.test.ts @@ -12,7 +12,7 @@ vi.mock("node:os", () => ({ })); vi.mock("node:child_process", () => ({ - execSync: vi.fn(), + execFileSync: vi.fn(), spawn: vi.fn(), })); @@ -21,7 +21,7 @@ vi.mock("which", () => ({ })); import { existsSync } from "node:fs"; -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import which from "which"; import { findPython, @@ -33,7 +33,7 @@ import { import { loadConfig, saveConfig, getConfigPath } from "../../src/utils/config.js"; const mockExistsSync = vi.mocked(existsSync); -const mockExecSync = vi.mocked(execSync); +const mockExecFileSync = vi.mocked(execFileSync); const mockWhich = vi.mocked(which); beforeEach(() => { @@ -44,7 +44,7 @@ describe("init lifecycle", () => { describe("Python detection", () => { it("finds Python 3.12+ via which", async () => { mockWhich.mockResolvedValue("/usr/bin/python3" as never); - mockExecSync.mockReturnValue("Python 3.12.3\n"); + mockExecFileSync.mockReturnValue("Python 3.12.3\n"); const result = await findPython(); expect(result).toBe("/usr/bin/python3"); @@ -52,7 +52,7 @@ describe("init lifecycle", () => { it("rejects Python < 3.12", async () => { mockWhich.mockResolvedValue("/usr/bin/python3" as never); - mockExecSync.mockReturnValue("Python 3.10.12\n"); + mockExecFileSync.mockReturnValue("Python 3.10.12\n"); const result = await findPython(); expect(result).toBeNull(); @@ -68,19 +68,19 @@ describe("init lifecycle", () => { describe("getPythonVersion", () => { it("parses Python version string", () => { - mockExecSync.mockReturnValue("Python 3.12.3\n"); + mockExecFileSync.mockReturnValue("Python 3.12.3\n"); const version = getPythonVersion("/usr/bin/python3"); expect(version).toEqual({ major: 3, minor: 12, patch: 3 }); }); it("returns null for invalid output", () => { - mockExecSync.mockReturnValue("Not Python\n"); + mockExecFileSync.mockReturnValue("Not Python\n"); const version = getPythonVersion("/usr/bin/python3"); expect(version).toBeNull(); }); it("returns null when exec fails", () => { - mockExecSync.mockImplementation(() => { + mockExecFileSync.mockImplementation(() => { throw new Error("not found"); }); const version = getPythonVersion("/usr/bin/python3"); @@ -110,12 +110,12 @@ describe("init lifecycle", () => { describe("aceteam-nodes detection", () => { it("returns true when import succeeds", () => { - mockExecSync.mockReturnValue(""); + mockExecFileSync.mockReturnValue(""); expect(isAceteamNodesInstalled("/usr/bin/python3")).toBe(true); }); it("returns false when import fails", () => { - mockExecSync.mockImplementation(() => { + mockExecFileSync.mockImplementation(() => { throw new Error("ModuleNotFoundError"); }); expect(isAceteamNodesInstalled("/usr/bin/python3")).toBe(false); diff --git a/tests/patterns/patterns.test.ts b/tests/patterns/patterns.test.ts new file mode 100644 index 0000000..ff0f01b --- /dev/null +++ b/tests/patterns/patterns.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { BUILTIN_PATTERNS, getPatternById } from "../../src/patterns/index.js"; + +describe("BUILTIN_PATTERNS", () => { + it("has 10 built-in patterns", () => { + expect(BUILTIN_PATTERNS).toHaveLength(10); + }); + + it("has unique IDs", () => { + const ids = BUILTIN_PATTERNS.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("has 7 general and 3 government patterns", () => { + const general = BUILTIN_PATTERNS.filter((p) => p.category === "general"); + const gov = BUILTIN_PATTERNS.filter((p) => p.category === "government"); + expect(general).toHaveLength(7); + expect(gov).toHaveLength(3); + }); + + it("every pattern has required fields", () => { + for (const p of BUILTIN_PATTERNS) { + expect(p.id).toBeTruthy(); + expect(p.name).toBeTruthy(); + expect(p.description).toBeTruthy(); + expect(p.category).toBeTruthy(); + expect(p.systemPrompt).toBeTruthy(); + expect(p.systemPrompt.length).toBeGreaterThan(50); + } + }); + + it("pattern IDs use kebab-case", () => { + for (const p of BUILTIN_PATTERNS) { + expect(p.id).toMatch(/^[a-z][a-z0-9-]*$/); + } + }); +}); + +describe("getPatternById", () => { + it("finds existing pattern", () => { + const pattern = getPatternById("summarize"); + expect(pattern).toBeDefined(); + expect(pattern?.id).toBe("summarize"); + expect(pattern?.category).toBe("general"); + }); + + it("finds government pattern", () => { + const pattern = getPatternById("department-scanner"); + expect(pattern).toBeDefined(); + expect(pattern?.category).toBe("government"); + }); + + it("returns undefined for unknown pattern", () => { + expect(getPatternById("nonexistent")).toBeUndefined(); + }); +}); diff --git a/tests/utils/patterns.test.ts b/tests/utils/patterns.test.ts new file mode 100644 index 0000000..e4d5855 --- /dev/null +++ b/tests/utils/patterns.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +// Mock fs before importing the module +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(actual.existsSync), + readFileSync: vi.fn(actual.readFileSync), + readdirSync: vi.fn(actual.readdirSync), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + unlinkSync: vi.fn(), + }; +}); + +vi.mock("../../src/utils/config.js", () => ({ + loadConfig: vi.fn(() => ({ default_model: "gpt-4o-mini" })), +})); + +vi.mock("../../src/utils/python.js", () => ({ + runWorkflow: vi.fn(() => + Promise.resolve({ + success: true, + output: { response: "Mock workflow response" }, + }) + ), +})); + +vi.mock("../../src/utils/errors.js", () => ({ + classifyPythonError: vi.fn((msg: string) => ({ message: msg })), +})); + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { + listPatterns, + loadPattern, + readInputFile, + scanInputDir, + runPattern, +} from "../../src/utils/patterns.js"; +import { BUILTIN_PATTERNS } from "../../src/patterns/index.js"; +import { runWorkflow } from "../../src/utils/python.js"; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockReaddirSync = vi.mocked(readdirSync); +const mockRunWorkflow = vi.mocked(runWorkflow); + +describe("loadPattern", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads a built-in pattern by ID", () => { + const userPatternsDir = join(homedir(), ".ace", "patterns"); + mockExistsSync.mockImplementation((path) => { + if (String(path).startsWith(userPatternsDir)) return false; + return false; + }); + + const pattern = loadPattern("summarize"); + expect(pattern).toBeDefined(); + expect(pattern?.id).toBe("summarize"); + }); + + it("returns undefined for unknown pattern", () => { + mockExistsSync.mockReturnValue(false); + + const pattern = loadPattern("nonexistent"); + expect(pattern).toBeUndefined(); + }); + + it("loads user pattern that overrides built-in", () => { + const userPatternsDir = join(homedir(), ".ace", "patterns"); + const systemFile = join(userPatternsDir, "summarize", "system.md"); + + mockExistsSync.mockImplementation((path) => { + if (String(path) === systemFile) return true; + return false; + }); + mockReadFileSync.mockImplementation((path) => { + if (String(path) === systemFile) return "Custom summary prompt"; + throw new Error("not found"); + }); + + const pattern = loadPattern("summarize"); + expect(pattern).toBeDefined(); + expect(pattern?.category).toBe("user"); + expect(pattern?.systemPrompt).toBe("Custom summary prompt"); + }); +}); + +describe("listPatterns", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns all built-in patterns when no user patterns exist", () => { + mockExistsSync.mockReturnValue(false); + + const patterns = listPatterns(); + expect(patterns.length).toBe(BUILTIN_PATTERNS.length); + }); + + it("includes user patterns alongside built-ins", () => { + const userPatternsDir = join(homedir(), ".ace", "patterns"); + + mockExistsSync.mockImplementation((path) => { + if (String(path) === userPatternsDir) return true; + if (String(path) === join(userPatternsDir, "custom-pattern", "system.md")) + return true; + return false; + }); + + mockReaddirSync.mockReturnValue([ + { name: "custom-pattern", isDirectory: () => true, isFile: () => false } as unknown as import("node:fs").Dirent, + ]); + + mockReadFileSync.mockImplementation((path) => { + if ( + String(path) === + join(userPatternsDir, "custom-pattern", "system.md") + ) + return "Custom prompt"; + throw new Error("not found"); + }); + + const patterns = listPatterns(); + expect(patterns.length).toBe(BUILTIN_PATTERNS.length + 1); + expect(patterns.find((p) => p.id === "custom-pattern")).toBeDefined(); + }); +}); + +describe("readInputFile", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reads a text file", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(" Hello world "); + + const content = readInputFile("/path/to/file.txt"); + expect(content).toBe("Hello world"); + }); + + it("throws for nonexistent file", () => { + mockExistsSync.mockReturnValue(false); + expect(() => readInputFile("/nonexistent.txt")).toThrow("File not found"); + }); + + it("throws for unsupported file type", () => { + mockExistsSync.mockReturnValue(true); + expect(() => readInputFile("/file.docx")).toThrow("Unsupported file type"); + }); + + it("accepts supported extensions", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue("content"); + + for (const ext of [".txt", ".md", ".csv", ".json"]) { + expect(() => readInputFile(`/file${ext}`)).not.toThrow(); + } + }); +}); + +describe("scanInputDir", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws for nonexistent directory", () => { + mockExistsSync.mockReturnValue(false); + expect(() => scanInputDir("/nonexistent")).toThrow("Directory not found"); + }); + + it("returns sorted supported files", () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + { name: "b.txt", isFile: () => true, isDirectory: () => false } as unknown as import("node:fs").Dirent, + { name: "a.md", isFile: () => true, isDirectory: () => false } as unknown as import("node:fs").Dirent, + { name: "c.docx", isFile: () => true, isDirectory: () => false } as unknown as import("node:fs").Dirent, + { name: "subfolder", isFile: () => false, isDirectory: () => true } as unknown as import("node:fs").Dirent, + ]); + + const files = scanInputDir("/dir"); + expect(files).toEqual(["/dir/a.md", "/dir/b.txt"]); + }); +}); + +describe("runPattern", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("runs pattern via Python workflow engine", async () => { + const pattern = BUILTIN_PATTERNS.find((p) => p.id === "summarize")!; + const result = await runPattern("/usr/bin/python3", pattern, "Some text to summarize"); + + expect(mockRunWorkflow).toHaveBeenCalledOnce(); + expect(mockRunWorkflow).toHaveBeenCalledWith( + "/usr/bin/python3", + expect.stringContaining("ace-pattern-summarize"), + { prompt: "Some text to summarize" }, + { verbose: undefined } + ); + expect(result).toBe("Mock workflow response"); + }); + + it("respects model override", async () => { + const pattern = BUILTIN_PATTERNS[0]; + await runPattern("/usr/bin/python3", pattern, "test", { model: "gpt-4o" }); + + expect(mockRunWorkflow).toHaveBeenCalledOnce(); + // The model override is baked into the workflow JSON written to the temp file, + // so we just verify runWorkflow was called with the right python path + expect(mockRunWorkflow).toHaveBeenCalledWith( + "/usr/bin/python3", + expect.any(String), + { prompt: "test" }, + { verbose: undefined } + ); + }); + + it("returns JSON when json option is set", async () => { + const pattern = BUILTIN_PATTERNS[0]; + const result = await runPattern("/usr/bin/python3", pattern, "test", { json: true }); + + const parsed = JSON.parse(result); + expect(parsed.pattern).toBe(pattern.id); + expect(parsed.response).toBe("Mock workflow response"); + }); + + it("throws on workflow failure", async () => { + mockRunWorkflow.mockResolvedValueOnce({ + success: false, + error: "Python error", + output: null, + }); + + const pattern = BUILTIN_PATTERNS[0]; + await expect( + runPattern("/usr/bin/python3", pattern, "test") + ).rejects.toThrow("Python error"); + }); +});