├── .nvmrc ├── src ├── tests │ ├── integration │ │ ├── add.ts │ │ ├── add.test.ts │ │ ├── interactive.test.ts │ │ └── cli.test.ts │ ├── ternary │ │ ├── README.md │ │ ├── simplify.ts │ │ ├── simplify.test.ts │ │ └── simplify.prompt.md │ └── angular-parser │ │ ├── README.md │ │ ├── parse.test.ts │ │ ├── parse.prompt.md │ │ └── parse.ts ├── helpers │ ├── find-package-json.ts │ ├── remove-backticks.ts │ ├── constants.ts │ ├── remove-initial-slash.ts │ ├── file-exists.ts │ ├── validate-project.ts │ ├── exit-on-cancel.ts │ ├── output-file.ts │ ├── apply-unified-diff.ts │ ├── remove-backticks.test.ts │ ├── base64.ts │ ├── error.ts │ ├── find-visual-file.ts │ ├── remove-initial-slash.test.ts │ ├── iterate-on-test-command.ts │ ├── systemPrompt.ts │ ├── get-screenshot.ts │ ├── invalid-project-warning.ts │ ├── generate-ascii-tree.ts │ ├── apply-unified-diff.test.ts │ ├── generate-ascii-tree.test.ts │ ├── dependency-files.ts │ ├── iterate-on-test.ts │ ├── mock-llm.ts │ ├── generate.ts │ ├── get-test-command.ts │ ├── dependency-files.test.ts │ ├── test.ts │ ├── visual-test.ts │ ├── config.ts │ ├── run.ts │ ├── llm.test.ts │ ├── interactive-mode.ts │ ├── visual-generate.ts │ ├── config.test.ts │ └── llm.ts ├── images │ ├── original-label.png │ └── my-version-label.png ├── commands │ ├── update.ts │ └── config.ts └── cli.ts ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── .commitlintrc.json ├── combined-image.png ├── .husky ├── pre-commit └── commit-msg ├── .prettierrc ├── test ├── nextjs-app │ ├── app │ │ ├── page.png │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── next.config.mjs │ ├── postcss.config.mjs │ ├── .gitignore │ ├── package.json │ ├── tailwind.config.ts │ ├── public │ │ ├── vercel.svg │ │ └── next.svg │ ├── tsconfig.json │ └── README.md └── fixtures │ └── add.json ├── tsconfig.json ├── .versionrc.json ├── .github └── workflows │ └── buildOnPR.yml ├── .eslintrc.cjs ├── LICENSE ├── CONTRIBUTING.md ├── package.json ├── CHANGELOG.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.14.0 2 | -------------------------------------------------------------------------------- /src/tests/integration/add.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/helpers/find-package-json.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/integration/add.test.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | debug -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.prompt.md 2 | .next 3 | CHANGELOG.md -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["execa", "kolorist"] 3 | } 4 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /combined-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/micro-agent/main/combined-image.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /test/nextjs-app/app/page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/micro-agent/main/test/nextjs-app/app/page.png -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /src/images/original-label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/micro-agent/main/src/images/original-label.png -------------------------------------------------------------------------------- /src/images/my-version-label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/micro-agent/main/src/images/my-version-label.png -------------------------------------------------------------------------------- /test/nextjs-app/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/micro-agent/main/test/nextjs-app/app/favicon.ico -------------------------------------------------------------------------------- /test/nextjs-app/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /src/helpers/remove-backticks.ts: -------------------------------------------------------------------------------- 1 | export function removeBackticks(input: string): string { 2 | return input 3 | .replace(/[\s\S]*```(\w+)?\n([\s\S]*?)\n```[\s\S]*/gm, '$2') 4 | .trim(); 5 | } 6 | -------------------------------------------------------------------------------- /test/nextjs-app/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/tests/ternary/README.md: -------------------------------------------------------------------------------- 1 | ## Simplify ternary test 2 | 3 | Run with the below command: 4 | 5 | ```bash 6 | npm start -- src/tests/ternary/simplify.ts -t "npm test ternary && npm run typecheck" 7 | ``` 8 | -------------------------------------------------------------------------------- /src/helpers/constants.ts: -------------------------------------------------------------------------------- 1 | import pkg from '../../package.json'; 2 | 3 | export const commandName = 'micro-agent'; 4 | export const projectName = 'Micro Agent'; 5 | export const repoUrl = pkg.repository.url; 6 | -------------------------------------------------------------------------------- /src/helpers/remove-initial-slash.ts: -------------------------------------------------------------------------------- 1 | function removeInitialSlash(path: string): string { 2 | if (path.startsWith('/')) { 3 | return path.slice(1); 4 | } 5 | return path; 6 | } 7 | 8 | export { removeInitialSlash }; 9 | -------------------------------------------------------------------------------- /src/helpers/file-exists.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | 3 | export async function fileExists(filePath: string) { 4 | try { 5 | await fs.access(filePath); 6 | return true; 7 | } catch { 8 | return false; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/helpers/validate-project.ts: -------------------------------------------------------------------------------- 1 | import { getDependencyFile } from './dependency-files'; 2 | 3 | export async function isValidProject(): Promise { 4 | const fileContent = await getDependencyFile(); 5 | return fileContent !== null; 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers/exit-on-cancel.ts: -------------------------------------------------------------------------------- 1 | import { outro } from '@clack/prompts'; 2 | 3 | export const exitOnCancel = (value: string | symbol) => { 4 | if (typeof value === 'symbol') { 5 | outro('Goodbye!'); 6 | process.exit(0); 7 | } 8 | return value; 9 | }; 10 | -------------------------------------------------------------------------------- /src/tests/angular-parser/README.md: -------------------------------------------------------------------------------- 1 | ## Angular Parser Test 2 | 3 | This test is used to test the Angular parser. The Angular parser is used to parse Angular components and extract information from them. 4 | 5 | Run this test locally with: 6 | 7 | ```bash 8 | npm start -- src/tests/angular-parser/parse.ts -t "npm test parser" 9 | ``` 10 | -------------------------------------------------------------------------------- /src/helpers/output-file.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, writeFile } from 'fs/promises'; 2 | import { dirname } from 'path'; 3 | 4 | export async function outputFile( 5 | filePath: string, 6 | content: string 7 | ): Promise { 8 | const dir = dirname(filePath); 9 | 10 | await mkdir(dir, { recursive: true }); 11 | await writeFile(filePath, content); 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "skipLibCheck": true 12 | }, 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /test/nextjs-app/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | body { 12 | color: rgb(var(--foreground-rgb)); 13 | } 14 | 15 | @layer utilities { 16 | .text-balance { 17 | text-wrap: balance; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.versionrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { "type": "feat", "section": "Features" }, 4 | { "type": "fix", "section": "Bug Fixes" }, 5 | { "type": "chore", "hidden": true }, 6 | { "type": "docs", "hidden": true }, 7 | { "type": "style", "hidden": true }, 8 | { "type": "refactor", "hidden": true }, 9 | { "type": "perf", "hidden": true }, 10 | { "type": "test", "hidden": true } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/apply-unified-diff.ts: -------------------------------------------------------------------------------- 1 | import { parsePatch, applyPatch } from 'diff'; 2 | 3 | export function applyUnifiedDiff(diff: string, fileContent: string): string { 4 | const parsedDiff = parsePatch(diff); 5 | let str = fileContent; 6 | 7 | for (const patch of parsedDiff) { 8 | const result = applyPatch(fileContent, patch); 9 | if (result === false) { 10 | console.log('could NOT apply a patch', patch), patch.hunks; 11 | throw new Error('Failed to apply patch'); 12 | } 13 | str = result; 14 | } 15 | return str; 16 | } 17 | -------------------------------------------------------------------------------- /test/nextjs-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /test/nextjs-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "react": "^18", 13 | "react-dom": "^18", 14 | "next": "14.2.3" 15 | }, 16 | "devDependencies": { 17 | "typescript": "^5", 18 | "@types/node": "^20", 19 | "@types/react": "^18", 20 | "@types/react-dom": "^18", 21 | "postcss": "^8", 22 | "tailwindcss": "^3.4.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/nextjs-app/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter } from 'next/font/google'; 3 | import './globals.css'; 4 | 5 | const inter = Inter({ subsets: ['latin'] }); 6 | 7 | export const metadata: Metadata = { 8 | title: 'Create Next App', 9 | description: 'Generated by create next app', 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/helpers/remove-backticks.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import { removeBackticks } from './remove-backticks'; 3 | 4 | // Remove backticks from a string, for instance to remove the 5 | // markdown backticks (+ language name) around code returned that 6 | // should just be that code 7 | test('should remove backticks', () => { 8 | expect(removeBackticks('```\nhello\n```')).toBe('hello'); 9 | expect(removeBackticks('```typescript\nhello\nworld\n```')).toBe( 10 | 'hello\nworld' 11 | ); 12 | expect(removeBackticks('```js\nhello\nworld\n```')).toBe('hello\nworld'); 13 | }); 14 | -------------------------------------------------------------------------------- /src/helpers/base64.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | 3 | export const imageFilePathToBase64Url = async (imageFilePath: string) => { 4 | const image = await readFile(imageFilePath); 5 | const extension = imageFilePath.split('.').pop(); 6 | const imageBase64 = Buffer.from(image).toString('base64'); 7 | return `data:image/${ 8 | extension === 'jpg' ? 'jpeg' : extension 9 | };base64,${imageBase64}`; 10 | }; 11 | 12 | export const bufferToBase64Url = (buffer: Buffer) => { 13 | const imageBase64 = buffer.toString('base64'); 14 | return `data:image/png;base64,${imageBase64}`; 15 | }; 16 | -------------------------------------------------------------------------------- /test/nextjs-app/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /test/nextjs-app/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/nextjs-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/buildOnPR.yml: -------------------------------------------------------------------------------- 1 | name: Build on PR 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | run-build: 7 | name: Run Build 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Check out Git repository 12 | uses: actions/checkout@v2 13 | 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 18 18 | 19 | - name: Install Node.js dependencies 20 | run: npm install 21 | - name: Lint 22 | run: npm run lint 23 | - name: Build 24 | run: npm run build 25 | - name: Unit Test 26 | run: npm test 27 | - name: Integration Test 28 | run: npm run test:integration 29 | -------------------------------------------------------------------------------- /src/commands/update.ts: -------------------------------------------------------------------------------- 1 | import { command } from 'cleye'; 2 | import { execaCommand } from 'execa'; 3 | import { dim } from 'kolorist'; 4 | 5 | export default command( 6 | { 7 | name: 'update', 8 | help: { 9 | description: 'Update Micro Agent to the latest version', 10 | }, 11 | }, 12 | async () => { 13 | console.log(''); 14 | const command = `npm update -g @builder.io/micro-agent`; 15 | console.log(dim(`Running: ${command}`)); 16 | console.log(''); 17 | await execaCommand(command, { 18 | stdio: 'inherit', 19 | shell: process.env.SHELL || true, 20 | }).catch(() => { 21 | // No need to handle, will go to stderr 22 | }); 23 | console.log(''); 24 | } 25 | ); 26 | -------------------------------------------------------------------------------- /src/helpers/error.ts: -------------------------------------------------------------------------------- 1 | import { dim } from 'kolorist'; 2 | import { version } from '../../package.json'; 3 | import { commandName } from './constants'; 4 | 5 | export class KnownError extends Error {} 6 | 7 | const indent = ' '.repeat(4); 8 | 9 | export const handleCliError = (error: any) => { 10 | if (error instanceof Error && !(error instanceof KnownError)) { 11 | if (error.stack) { 12 | console.error(dim(error.stack.split('\n').slice(1).join('\n'))); 13 | } 14 | console.error(`\n${indent}${dim(`${commandName} v${version}`)}`); 15 | console.error( 16 | `\n${indent}Please open a Bug report with the information above:` 17 | ); 18 | console.error( 19 | `${indent}https://github.com/BuilderIO/micro-agent/issues/new` 20 | ); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/helpers/find-visual-file.ts: -------------------------------------------------------------------------------- 1 | import { RunOptions } from './run'; 2 | import { glob } from 'glob'; 3 | 4 | const fileCache = new Map(); 5 | 6 | export async function findVisualFile(options: RunOptions) { 7 | const filename = options.outputFile; 8 | if (fileCache.has(filename)) { 9 | return fileCache.get(filename); 10 | } 11 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 12 | const fileExtension = filename.split('.').pop()!; 13 | const fileNameWithoutExtension = filename.replace( 14 | new RegExp('\\.' + fileExtension + '$'), 15 | '' 16 | ); 17 | const imageFiles = await glob( 18 | `${fileNameWithoutExtension}.{png,jpg,jpeg,svg,webp}` 19 | ); 20 | 21 | const imageFile = imageFiles[0]; 22 | fileCache.set(filename, imageFile); 23 | return imageFile; 24 | } 25 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | ecmaVersion: 'latest', 10 | sourceType: 'module', 11 | }, 12 | overrides: [{ files: ['src/**/*.ts'] }], 13 | plugins: ['@typescript-eslint', 'unused-imports'], 14 | rules: { 15 | 'no-async-promise-executor': 'off', 16 | '@typescript-eslint/no-explicit-any': 'off', 17 | 'no-unused-vars': 'off', 18 | '@typescript-eslint/no-unused-vars': 'off', 19 | '@typescript-eslint/no-non-null-assertion': 'off', 20 | 'unused-imports/no-unused-imports': 'error', 21 | 'unused-imports/no-unused-vars': [ 22 | 'warn', 23 | { 24 | vars: 'all', 25 | varsIgnorePattern: '^_', 26 | args: 'after-used', 27 | argsIgnorePattern: '^_', 28 | }, 29 | ], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/helpers/remove-initial-slash.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import { removeInitialSlash } from './remove-initial-slash'; 3 | 4 | test('removes initial slash from an absolute path', () => { 5 | expect(removeInitialSlash('/absolute/path')).toBe('absolute/path'); 6 | expect(removeInitialSlash('/')).toBe(''); 7 | expect(removeInitialSlash('/another/path/with/multiple/segments')).toBe( 8 | 'another/path/with/multiple/segments' 9 | ); 10 | expect(removeInitialSlash('/singlefolder')).toBe('singlefolder'); 11 | }); 12 | 13 | test('returns the same string if the path is already relative', () => { 14 | expect(removeInitialSlash('relative/path')).toBe('relative/path'); 15 | expect(removeInitialSlash('another/relative/path')).toBe( 16 | 'another/relative/path' 17 | ); 18 | expect(removeInitialSlash('justonefolder')).toBe('justonefolder'); 19 | expect(removeInitialSlash('relative')).toBe('relative'); 20 | }); 21 | 22 | test('handles empty string input', () => { 23 | expect(removeInitialSlash('')).toBe(''); 24 | }); 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Builder.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/helpers/iterate-on-test-command.ts: -------------------------------------------------------------------------------- 1 | import { log, text } from '@clack/prompts'; 2 | import { execaCommand } from 'execa'; 3 | import { gray } from 'kolorist'; 4 | import { exitOnCancel } from './exit-on-cancel'; 5 | import { isInvalidCommand } from './test'; 6 | 7 | export async function iterateOnTestCommand({ 8 | testCommand, 9 | }: { 10 | testCommand: string; 11 | }) { 12 | const result = execaCommand(testCommand).catch((err) => err); 13 | const final = await result; 14 | if (isInvalidCommand(final.stderr)) { 15 | log.error('Your test command is invalid. Please try again.'); 16 | log.message( 17 | `Your test script output:\n${(final.stderr || final.message) 18 | .split('\n') 19 | .map(gray) 20 | .join('\n')}` 21 | ); 22 | 23 | const newTestCommand = exitOnCancel( 24 | await text({ 25 | message: 'What command should I run to test the code?', 26 | defaultValue: testCommand, 27 | placeholder: testCommand, 28 | }) 29 | ); 30 | 31 | return iterateOnTestCommand({ testCommand: newTestCommand }); 32 | } else { 33 | return testCommand; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/helpers/systemPrompt.ts: -------------------------------------------------------------------------------- 1 | export const systemPrompt = `You take a prompt and existing unit tests and generate the function implementation accordingly. 2 | 3 | 1. Think step by step about the algorithm, reasoning about the problem and the solution, similar algorithm, the state, data structures and strategy you will use. Explain all that without emitting any code in this step. 4 | 5 | 2. Emit a markdown code block with production-ready generated code (function that satisfies all the tests and the prompt). 6 | - Be sure your code exports function that can be called by an external test file. 7 | - Make sure your code is reusable and not overly hardcoded to match the prompt. 8 | - Use two spaces for indents. Add logs if helpful for debugging, you will get the log output on your next try to help you debug. 9 | - Always return a complete code snippet that can execute, nothing partial and never say "rest of your code" or similar, I will copy and paste your code into my file without modification, so it cannot have gaps or parts where you say to put the "rest of the code" back in. 10 | - Do not emit tests, just the function implementation. 11 | 12 | Stop emitting after the code block`; 13 | -------------------------------------------------------------------------------- /src/helpers/get-screenshot.ts: -------------------------------------------------------------------------------- 1 | import { findVisualFile } from './find-visual-file'; 2 | import { RunOptions } from './run'; 3 | import { chromium } from 'playwright'; 4 | import probe from 'probe-image-size'; 5 | import { createReadStream } from 'fs'; 6 | import { readFile } from 'fs/promises'; 7 | 8 | const cacheByCode = new Map(); 9 | 10 | // Use Playwright to get a screenshot of the given url 11 | export async function getScreenshot(options: RunOptions) { 12 | const currentCode = await readFile(options.outputFile, 'utf-8'); 13 | const cached = cacheByCode.get(currentCode); 14 | if (cached) { 15 | return cached; 16 | } 17 | 18 | const url = options.visual; 19 | const imageFile = await findVisualFile(options); 20 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 21 | const { width, height } = await probe(createReadStream(imageFile!)); 22 | 23 | const browser = await chromium.launch(); 24 | const page = await browser.newPage(); 25 | 26 | await page.setViewportSize({ width, height }); 27 | await page.goto(url); 28 | const screenshot = await page.screenshot({ type: 'png' }); 29 | await browser.close(); 30 | cacheByCode.set(currentCode, screenshot); 31 | return screenshot; 32 | } 33 | -------------------------------------------------------------------------------- /src/helpers/invalid-project-warning.ts: -------------------------------------------------------------------------------- 1 | import * as p from '@clack/prompts'; 2 | import { outro } from '@clack/prompts'; 3 | import { execaCommand } from 'execa'; 4 | import { dim, yellow } from 'kolorist'; 5 | 6 | export async function invalidProjectWarningMessage() { 7 | console.warn( 8 | yellow( 9 | 'Warning: The current directory does not appear to be a recognized project folder.' 10 | ) 11 | ); 12 | 13 | const choice = await p.select({ 14 | message: 'Want to setup a new project?', 15 | options: [ 16 | { 17 | label: 'Node + Vitest project', 18 | value: 'node-vitest', 19 | }, 20 | { 21 | label: 'Exit', 22 | value: 'cancel', 23 | }, 24 | ], 25 | }); 26 | 27 | if (choice === 'node-vitest') { 28 | const command = `npm init -y && npm install typescript tsx @types/node vitest -D && npx tsc --init`; 29 | 30 | console.log(''); 31 | 32 | console.log(dim(`Running: ${command}`)); 33 | 34 | await execaCommand(command, { 35 | stdio: 'inherit', 36 | shell: process.env.SHELL || true, 37 | }).catch(() => { 38 | // No need to handle, will go to stderr 39 | }); 40 | 41 | process.exit(); 42 | } else if (choice === 'cancel') { 43 | outro('Goodbye!'); 44 | process.exit(0); 45 | } 46 | 47 | process.exit(); 48 | } 49 | -------------------------------------------------------------------------------- /test/nextjs-app/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | ## Guidelines 4 | 5 | We would love your contributions to make this project better, and gladly accept PRs. 6 | 7 | Note: please try to keep changes incremental. Big refactors cause heartburn, please try to make frequent, small PRs instead of large ones. 8 | 9 | ## Setting up the project 10 | 11 | Use [nvm](https://nvm.sh) to use the appropriate Node.js version from `.nvmrc`: 12 | 13 | ```sh 14 | nvm i 15 | ``` 16 | 17 | Install the dependencies using npm: 18 | 19 | ```sh 20 | npm i 21 | ``` 22 | 23 | ## Building the project 24 | 25 | Run the `build` script: 26 | 27 | ```sh 28 | npm run build 29 | ``` 30 | 31 | The package is bundled using [pkgroll](https://github.com/privatenumber/pkgroll) (Rollup). It infers the entry-points from `package.json` so there are no build configurations. 32 | 33 | ### Development 34 | 35 | To run the CLI locally without installing it globally, you can use the `start` script: 36 | 37 | ```sh 38 | npm start 39 | ``` 40 | 41 | ## Check the lint in order to pass 42 | 43 | First, install prettier. 44 | 45 | ```sh 46 | npm run lint:fix 47 | ``` 48 | 49 | If you use Vscode, It is recommended to use [prettier-vscode](https://github.com/prettier/prettier-vscode) 50 | 51 | ## Send a pull request 52 | 53 | Once you have made your changes, push them to your fork and send a pull request to the main repository. We will try to review your changes in a timely manner. 54 | -------------------------------------------------------------------------------- /test/nextjs-app/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /src/tests/angular-parser/parse.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import { parse } from './parse'; 3 | 4 | test('parse Angular component', () => { 5 | const componentString = ` 6 | import { Component, Input } from '@angular/core'; 7 | 8 | @Component({ 9 | selector: 'example-component', 10 | templateUrl: './example.component.html', 11 | styleUrls: ['./example.component.css'] 12 | }) 13 | export class ExampleComponent { 14 | @Input() title: string; 15 | @Input() description: string; 16 | @Input() options: Option; 17 | @Input() mode: number; 18 | } 19 | `; 20 | 21 | const expected = { 22 | name: 'ExampleComponent', 23 | selector: 'example-component', 24 | inputs: [ 25 | { name: 'title', type: 'string' }, 26 | { name: 'description', type: 'string' }, 27 | { name: 'options', type: 'Option' }, 28 | { name: 'mode', type: 'number' }, 29 | ], 30 | }; 31 | 32 | const result = parse(componentString); 33 | expect(result).toEqual(expected); 34 | }); 35 | 36 | test('parse another Angular component', () => { 37 | const componentString = ` 38 | import { Component, Input } from '@angular/core'; 39 | 40 | type Foo = number; 41 | 42 | @Component({ 43 | selector: 'my-component', 44 | }) 45 | export class MyComponent { 46 | @Input() num: Foo; 47 | } 48 | `; 49 | 50 | const expected = { 51 | name: 'MyComponent', 52 | selector: 'my-component', 53 | inputs: [{ name: 'num', type: 'number' }], 54 | }; 55 | 56 | const result = parse(componentString); 57 | expect(result).toEqual(expected); 58 | }); 59 | -------------------------------------------------------------------------------- /src/helpers/generate-ascii-tree.ts: -------------------------------------------------------------------------------- 1 | export function generateAsciiTree(paths: (string | null | number)[]): string { 2 | if (!paths.every((path) => typeof path === 'string')) { 3 | throw new TypeError('All elements in the paths array must be strings.'); 4 | } 5 | 6 | // Define the Tree Node structure 7 | type TreeNode = { 8 | [key: string]: TreeNode | null; 9 | }; 10 | 11 | const root: TreeNode = {}; 12 | 13 | // Function to add paths to the tree 14 | function addPathToTree(path: string, node: TreeNode) { 15 | const parts = path.split('/'); 16 | let current = node; 17 | 18 | parts.forEach((part, index) => { 19 | if (!current[part]) { 20 | current[part] = index === parts.length - 1 ? null : {}; 21 | } 22 | current = current[part] as TreeNode; 23 | }); 24 | } 25 | 26 | // Construct the tree 27 | paths.forEach((path) => { 28 | if (typeof path === 'string') { 29 | addPathToTree(path, root); 30 | } 31 | }); 32 | 33 | // Function to generate ASCII tree 34 | function generateTreeString( 35 | node: TreeNode, 36 | prefix: string = '', 37 | isLast: boolean = true 38 | ): string { 39 | const keys = Object.keys(node); 40 | let result = ''; 41 | 42 | keys.forEach((key, index) => { 43 | const isThisLast = index === keys.length - 1; 44 | result += prefix + (isThisLast ? '└── ' : '├── ') + key + '\n'; 45 | 46 | // Generate subtree if the current node is a directory 47 | if (node[key]) { 48 | result += generateTreeString( 49 | node[key] as TreeNode, 50 | prefix + (isThisLast ? ' ' : '│ '), 51 | isThisLast 52 | ); 53 | } 54 | }); 55 | 56 | return result; 57 | } 58 | 59 | return generateTreeString(root).trim(); 60 | } 61 | -------------------------------------------------------------------------------- /src/tests/angular-parser/parse.prompt.md: -------------------------------------------------------------------------------- 1 | Parse an angular component as a string and return metadata about its name and inputs. 2 | 3 | For example, this 4 | 5 | ```ts 6 | type Option = 'a' | 'b' | 'c'; 7 | 8 | @Component({ 9 | selector: 'example-component', 10 | template: ` 11 |
12 |

{{ title }}

13 |

{{ description }}

14 |
15 | `, 16 | }) 17 | export class ExampleComponent { 18 | @Input() title: string; 19 | @Input() description: string; 20 | @Input() options: Option; 21 | @Input() mode: number; 22 | } 23 | ``` 24 | 25 | should return this 26 | 27 | ```ts 28 | { 29 | name: 'ExampleComponent', 30 | selector: 'example-component', 31 | inputs: [ 32 | { name: 'title', type: 'string' }, 33 | { name: 'description', type: 'string' }, 34 | { name: 'options', type: 'string', enum: ['a', 'b', 'c'] }, 35 | { name: 'mode', type: 'number' }, 36 | ], 37 | } 38 | ``` 39 | 40 | also this 41 | 42 | ```ts 43 | import { Component, Input } from '@angular/core'; 44 | 45 | type Foo = number; 46 | 47 | @Component({ 48 | selector: 'my-component', 49 | }) 50 | export class MyComponent { 51 | @Input() num: Foo; 52 | } 53 | ``` 54 | 55 | should return this 56 | 57 | ```ts 58 | { 59 | name: 'MyComponent', 60 | selector: 'my-component', 61 | inputs: [{ name: 'num', type: 'number' }], 62 | } 63 | ``` 64 | 65 | Do not hard code anything for this one test, it should take any Angular component and output its inputs and types as shown above. 66 | 67 | Note: do not use `node.decorators` in typecript, it is deprecated. Decorators has been removed from Node and merged with modifiers on the Node subtypes that support them. Use ts.canHaveDecorators() to test whether a Node can have decorators. Use ts.getDecorators() to get the decorators of a Node. 68 | -------------------------------------------------------------------------------- /src/commands/config.ts: -------------------------------------------------------------------------------- 1 | import { command } from 'cleye'; 2 | import { red } from 'kolorist'; 3 | import { 4 | hasOwn, 5 | getConfig, 6 | setConfigs, 7 | showConfigUI, 8 | } from '../helpers/config.js'; 9 | import { KnownError, handleCliError } from '../helpers/error.js'; 10 | import { outro } from '@clack/prompts'; 11 | 12 | export default command( 13 | { 14 | name: 'config', 15 | parameters: ['[mode]', '[key=value...]'], 16 | help: { 17 | description: 'Configure the CLI', 18 | }, 19 | }, 20 | (argv) => { 21 | (async () => { 22 | const { mode, keyValue: keyValues } = argv._; 23 | 24 | if (mode === 'ui' || !mode) { 25 | await showConfigUI(); 26 | return; 27 | } 28 | 29 | if (!keyValues.length) { 30 | console.error(`Error: Missing required parameter "key=value"\n`); 31 | argv.showHelp(); 32 | return process.exit(1); 33 | } 34 | 35 | if (mode === 'get') { 36 | const config = await getConfig(); 37 | for (const key of keyValues) { 38 | if (hasOwn(config, key)) { 39 | console.log(`${key}=${config[key as keyof typeof config]}`); 40 | } else { 41 | throw new KnownError(`Invalid config property: ${key}`); 42 | } 43 | } 44 | return; 45 | } 46 | 47 | if (mode === 'set') { 48 | await setConfigs( 49 | keyValues.map((keyValue) => keyValue.split('=') as [string, string]) 50 | ); 51 | 52 | outro('Config updated ✅'); 53 | return; 54 | } 55 | 56 | throw new KnownError(`Invalid mode: ${mode}`); 57 | })().catch((error) => { 58 | console.error(`\n${red('✖')} ${error.message}`); 59 | handleCliError(error); 60 | process.exit(1); 61 | }); 62 | } 63 | ); 64 | -------------------------------------------------------------------------------- /src/helpers/apply-unified-diff.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { applyUnifiedDiff } from './apply-unified-diff'; // Adjust the path accordingly 3 | 4 | describe('applyUnifiedDiff', () => { 5 | it('should apply a simple unified diff correctly', () => { 6 | const diff = `--- a/test.txt 7 | +++ b/test.txt 8 | @@ -1,3 +1,3 @@ 9 | -Hello 10 | -World 11 | +Hi 12 | +Universe 13 | This is a test.`; 14 | 15 | const originalFileContent = `Hello 16 | World 17 | This is a test.`; 18 | 19 | const expectedFileContent = `Hi 20 | Universe 21 | This is a test.`; 22 | 23 | const result = applyUnifiedDiff(diff, originalFileContent); 24 | expect(result).toBe(expectedFileContent); 25 | }); 26 | 27 | it('should handle an empty diff', () => { 28 | const diff = ''; 29 | const originalFileContent = 'No changes here.'; 30 | const result = applyUnifiedDiff(diff, originalFileContent); 31 | expect(result).toBe(originalFileContent); 32 | }); 33 | 34 | it.skip('should throw an error for an invalid diff', () => { 35 | const diff = 'invalid diff'; 36 | const originalFileContent = 'Original content.'; 37 | 38 | expect(() => applyUnifiedDiff(diff, originalFileContent)).toThrow( 39 | 'Failed to apply patch' 40 | ); 41 | }); 42 | 43 | it('should handle multiple changes in the diff', () => { 44 | const diff = `--- a/test.txt 45 | +++ b/test.txt 46 | @@ -1,5 +1,5 @@ 47 | -Hello 48 | +Hi 49 | World 50 | This is a test. 51 | -Another line. 52 | +Changed line.`; 53 | 54 | const originalFileContent = `Hello 55 | World 56 | This is a test. 57 | Another line.`; 58 | 59 | const expectedFileContent = `Hi 60 | World 61 | This is a test. 62 | Changed line.`; 63 | 64 | const result = applyUnifiedDiff(diff, originalFileContent); 65 | expect(result).toBe(expectedFileContent); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/helpers/generate-ascii-tree.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import { generateAsciiTree } from './generate-ascii-tree'; 3 | 4 | test('generates a simple tree for a single file', () => { 5 | const input = ['file1.txt']; 6 | const expectedOutput = '└── file1.txt'; 7 | expect(generateAsciiTree(input)).toBe(expectedOutput); 8 | }); 9 | 10 | test('generates a tree for multiple files in the same directory', () => { 11 | const input = ['file1.txt', 'file2.txt']; 12 | const expectedOutput = `├── file1.txt 13 | └── file2.txt`; 14 | expect(generateAsciiTree(input)).toBe(expectedOutput); 15 | }); 16 | 17 | test('generates a tree for files in nested directories', () => { 18 | const input = ['dir1/file1.txt', 'dir1/file2.txt', 'dir2/file3.txt']; 19 | const expectedOutput = `├── dir1 20 | │ ├── file1.txt 21 | │ └── file2.txt 22 | └── dir2 23 | └── file3.txt`; 24 | expect(generateAsciiTree(input)).toBe(expectedOutput); 25 | }); 26 | 27 | test('handles an empty list of file paths', () => { 28 | const input = []; 29 | const expectedOutput = ''; 30 | expect(generateAsciiTree(input)).toBe(expectedOutput); 31 | }); 32 | 33 | test('handles files in deeply nested directories', () => { 34 | const input = ['dir1/dir2/dir3/file1.txt']; 35 | const expectedOutput = `└── dir1 36 | └── dir2 37 | └── dir3 38 | └── file1.txt`; 39 | expect(generateAsciiTree(input)).toBe(expectedOutput); 40 | }); 41 | 42 | test('handles files with similar prefixes', () => { 43 | const input = ['dir1/file1.txt', 'dir1file2.txt']; 44 | const expectedOutput = `├── dir1 45 | │ └── file1.txt 46 | └── dir1file2.txt`; 47 | expect(generateAsciiTree(input)).toBe(expectedOutput); 48 | }); 49 | 50 | test('handles invalid input with non-string elements', () => { 51 | const input = ['file1.txt', 123, null, 'file2.txt']; 52 | expect(() => generateAsciiTree(input)).toThrow(TypeError); 53 | }); 54 | -------------------------------------------------------------------------------- /src/tests/integration/interactive.test.ts: -------------------------------------------------------------------------------- 1 | import { execaCommand } from 'execa'; 2 | import { lstat, writeFile } from 'fs/promises'; 3 | import { beforeAll, describe, expect, it } from 'vitest'; 4 | 5 | const checkConfigFileExists = async () => { 6 | return await lstat(`${process.env.HOME}/.micro-agent`) 7 | .then(() => true) 8 | .catch(() => false); 9 | }; 10 | 11 | describe('interactive cli', () => { 12 | beforeAll(async () => { 13 | const configFileExists = await checkConfigFileExists(); 14 | if (!configFileExists) { 15 | await writeFile( 16 | `${process.env.HOME}/.micro-agent`, 17 | 'OPENAI_KEY=sk-1234567890abcdef1234567890abcdef' 18 | ); 19 | } 20 | }); 21 | it('should start interactive mode with an intro', async () => { 22 | const result = await execaCommand('jiti ./src/cli.ts', { 23 | input: '\x03', 24 | shell: process.env.SHELL || true, 25 | }); 26 | 27 | const output = result.stdout; 28 | 29 | expect(output).toContain('🦾 Micro Agent'); 30 | }); 31 | 32 | it('should ask for an OpenAI key if not set', async () => { 33 | // Rename the config file to simulate a fresh install 34 | await execaCommand('mv ~/.micro-agent ~/.micro-agent.bak', { 35 | shell: process.env.SHELL || true, 36 | }); 37 | const result = await execaCommand('jiti ./src/cli.ts', { 38 | input: '\x03', 39 | shell: process.env.SHELL || true, 40 | }); 41 | 42 | const output = result.stdout; 43 | 44 | expect(output).toContain('Welcome newcomer! What is your OpenAI key?'); 45 | 46 | // Restore the config file 47 | await execaCommand('mv ~/.micro-agent.bak ~/.micro-agent', { 48 | shell: process.env.SHELL || true, 49 | }); 50 | }); 51 | 52 | it('should ask for a prompt', async () => { 53 | const result = await execaCommand('jiti ./src/cli.ts', { 54 | input: '\x03', 55 | shell: process.env.SHELL || true, 56 | }); 57 | 58 | const output = result.stdout; 59 | 60 | expect(output).toContain('What would you like to do?'); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/helpers/dependency-files.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import * as path from 'path'; 3 | import { fileExists } from './file-exists'; 4 | 5 | /** 6 | * Find dependency file in the given directory or any 7 | * parent directory. Returns the content of the dependency file. 8 | */ 9 | export async function getDependencyFile( 10 | directory = process.cwd(), 11 | language?: string 12 | ): Promise { 13 | let currentDirectory = directory; 14 | const rootDirectory = path.parse(directory).root; 15 | while (currentDirectory !== rootDirectory || rootDirectory == '') { 16 | if (language) { 17 | const filePath = getDependenciesFilePath(directory, language); 18 | 19 | if (await fileExists(filePath)) { 20 | return getDependenciesFileContent(directory, language); 21 | } 22 | } else { 23 | let filePath = getDependenciesFilePath(directory, 'py'); 24 | if (await fileExists(filePath)) { 25 | return getDependenciesFileContent(directory, 'py'); 26 | } 27 | filePath = getDependenciesFilePath(directory, 'rb'); 28 | if (await fileExists(filePath)) { 29 | return getDependenciesFileContent(directory, 'rb'); 30 | } 31 | filePath = getDependenciesFilePath(directory, 'js'); 32 | if (await fileExists(filePath)) { 33 | return getDependenciesFileContent(directory, 'js'); 34 | } 35 | } 36 | currentDirectory = path.dirname(currentDirectory); 37 | } 38 | return null; 39 | } 40 | 41 | export function getDependencyFileName(language?: string): string { 42 | let fileName; 43 | switch (language) { 44 | case 'py': 45 | fileName = 'requirements.txt'; 46 | break; 47 | case 'rb': 48 | fileName = 'Gemfile'; 49 | break; 50 | default: 51 | fileName = 'package.json'; 52 | break; 53 | } 54 | return fileName; 55 | } 56 | 57 | function getDependenciesFilePath(directory: string, language?: string): string { 58 | const fileName = getDependencyFileName(language); 59 | return path.join(directory, fileName); 60 | } 61 | 62 | async function getDependenciesFileContent( 63 | directory = process.cwd(), 64 | language?: string 65 | ): Promise { 66 | const filePath = getDependenciesFilePath(directory, language); 67 | const fileContent = await readFile(filePath, 'utf8'); 68 | return fileContent; 69 | } 70 | -------------------------------------------------------------------------------- /src/helpers/iterate-on-test.ts: -------------------------------------------------------------------------------- 1 | import { text } from '@clack/prompts'; 2 | import { exitOnCancel } from './exit-on-cancel'; 3 | import { RunOptions } from './run'; 4 | import { getSimpleCompletion } from './llm'; 5 | import { formatMessage } from './test'; 6 | import dedent from 'dedent'; 7 | import { getCodeBlock } from './interactive-mode'; 8 | 9 | export async function iterateOnTest({ 10 | testCode, 11 | feedback, 12 | options, 13 | }: { 14 | testCode: string; 15 | feedback: string; 16 | options: Partial; 17 | }) { 18 | process.stderr.write(formatMessage('\n')); 19 | let testContents = getCodeBlock( 20 | (await getSimpleCompletion({ 21 | onChunk: (chunk) => { 22 | process.stderr.write(formatMessage(chunk)); 23 | }, 24 | messages: [ 25 | { 26 | role: 'system', 27 | content: 28 | 'You return code for a unit test only. No other words, just the code', 29 | }, 30 | { 31 | role: 'user', 32 | content: dedent` 33 | Here is a unit test file generated from the following prompt 34 | 35 | ${options.prompt} 36 | 37 | 38 | The test will be located at \`${options.testFile}\` and the code to test will be located at 39 | \`${options.outputFile}\`. 40 | 41 | The current test code is: 42 | 43 | ${testCode} 44 | 45 | 46 | The user has given you this feedback on the test. Please update (or completley rewrite, 47 | if neededed) the test based on the feedback. 48 | 49 | 50 | ${feedback} 51 | 52 | 53 | Please give me new code addressing the feedback.s 54 | 55 | `, 56 | }, 57 | ], 58 | }))! 59 | ); 60 | console.log(formatMessage('\n')); 61 | 62 | const result = exitOnCancel( 63 | await text({ 64 | message: 65 | 'How does the generated test look? Reply "good", or provide feedback', 66 | defaultValue: 'good', 67 | placeholder: 'good', 68 | }) 69 | ); 70 | 71 | if (result.toLowerCase().trim() !== 'good') { 72 | testContents = await iterateOnTest({ 73 | testCode: testContents, 74 | feedback: result, 75 | options, 76 | }); 77 | } 78 | 79 | return testContents; 80 | } 81 | -------------------------------------------------------------------------------- /src/helpers/mock-llm.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'fs/promises'; 2 | import { KnownError } from './error'; 3 | import { formatMessage } from './test'; 4 | import OpenAI from 'openai'; 5 | 6 | const readMockLlmRecordFile = async ( 7 | mockLlmRecordFile: string 8 | ): Promise<{ completions: any[] }> => { 9 | const mockLlmRecordFileContents = await readFile( 10 | mockLlmRecordFile, 11 | 'utf-8' 12 | ).catch(() => ''); 13 | let jsonLlmRecording; 14 | try { 15 | jsonLlmRecording = JSON.parse(mockLlmRecordFileContents.toString()); 16 | } catch { 17 | jsonLlmRecording = { completions: [] }; 18 | } 19 | return jsonLlmRecording; 20 | }; 21 | 22 | export const captureLlmRecord = async ( 23 | messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[], 24 | output: string, 25 | mockLlmRecordFile?: string 26 | ) => { 27 | if (mockLlmRecordFile) { 28 | const jsonLlmRecording = await readMockLlmRecordFile(mockLlmRecordFile); 29 | 30 | jsonLlmRecording.completions.push({ 31 | inputs: messages, 32 | output: output, 33 | }); 34 | 35 | await writeFile( 36 | mockLlmRecordFile, 37 | JSON.stringify(jsonLlmRecording, null, 2) 38 | ); 39 | } 40 | }; 41 | export const mockedLlmCompletion = async ( 42 | mockLlmRecordFile: string | undefined, 43 | messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] 44 | ) => { 45 | if (!mockLlmRecordFile) { 46 | throw new KnownError( 47 | 'You need to set the MOCK_LLM_RECORD_FILE environment variable to use the mock LLM' 48 | ); 49 | } 50 | const jsonLlmRecording = await readMockLlmRecordFile(mockLlmRecordFile); 51 | const completion = jsonLlmRecording.completions.find( 52 | (completion: { inputs: any }) => { 53 | // Match on system input only 54 | const content = completion.inputs[0].content; 55 | if (typeof messages[0].content === 'string') { 56 | return messages[0].content.includes(content); 57 | } 58 | return ( 59 | JSON.stringify(completion.inputs[0]) === 60 | JSON.stringify(messages[0].content) 61 | ); 62 | } 63 | ); 64 | if (!completion) { 65 | throw new KnownError( 66 | `No completion found for the given system input in the MOCK_LLM_RECORD_FILE: ${JSON.stringify( 67 | messages[0] 68 | )}` 69 | ); 70 | } 71 | process.stdout.write(formatMessage('\n')); 72 | process.stderr.write(formatMessage(completion.output)); 73 | return completion.output; 74 | }; 75 | -------------------------------------------------------------------------------- /src/helpers/generate.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { getCompletion } from './llm'; 3 | import { readFile } from 'fs/promises'; 4 | import { blue } from 'kolorist'; 5 | import { RunOptions } from './run'; 6 | import { systemPrompt } from './systemPrompt'; 7 | 8 | export async function generate(options: RunOptions) { 9 | const prompt = await readFile(options.promptFile, 'utf-8').catch(() => ''); 10 | const priorCode = await readFile(options.outputFile, 'utf-8').catch(() => ''); 11 | const testCode = await readFile(options.testFile, 'utf-8'); 12 | 13 | const packageJson = await readFile('package.json', 'utf-8').catch(() => ''); 14 | 15 | const userPrompt = dedent` 16 | Here is what I need: 17 | 18 | 19 | ${prompt || 'Pass the tests'} 20 | 21 | 22 | The current code is: 23 | 24 | ${priorCode || 'None'} 25 | 26 | 27 | The file path for the above is ${options.outputFile}. 28 | 29 | The test code that needs to pass is: 30 | 31 | ${testCode} 32 | 33 | 34 | The file path for the test is ${options.testFile}. 35 | 36 | The error you received on that code was: 37 | 38 | ${options.lastRunError || 'None'} 39 | 40 | 41 | ${ 42 | packageJson && 43 | dedent` 44 | Don't use any node modules that aren't included here unless specifically told otherwise: 45 | 46 | ${packageJson} 47 | ` 48 | } 49 | 50 | Please update the code (or generate all new code if needed) to satisfy the prompt and test. 51 | 52 | Be sure to use good coding conventions. For instance, if you are generating a typescript 53 | file, use types (e.g. for function parameters, etc). 54 | 55 | ${ 56 | !options.interactive && 57 | dedent` 58 | If there is already existing code, strictly maintain the same coding style as the existing code. 59 | Any updated code should look like its written by the same person/team that wrote the original code. 60 | ` 61 | } 62 | `; 63 | 64 | if (process.env.MA_DEBUG) { 65 | console.log(`\n\n${blue('Prompt:')}`, userPrompt, '\n\n'); 66 | } 67 | 68 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 69 | return (await getCompletion({ 70 | options, 71 | messages: [ 72 | { 73 | role: 'system', 74 | content: systemPrompt, 75 | }, 76 | { 77 | role: 'user', 78 | content: userPrompt, 79 | }, 80 | ], 81 | }))!; 82 | } 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@builder.io/micro-agent", 3 | "description": "An AI CLI that writes code for you.", 4 | "version": "0.1.5", 5 | "type": "module", 6 | "dependencies": { 7 | "@anthropic-ai/sdk": "^0.21.1", 8 | "@clack/core": "latest", 9 | "@clack/prompts": "latest", 10 | "@commitlint/cli": "^19.3.0", 11 | "@commitlint/config-conventional": "^19.2.2", 12 | "@dqbd/tiktoken": "^1.0.15", 13 | "@types/diff": "^5.2.1", 14 | "@types/probe-image-size": "^7.2.4", 15 | "cleye": "^1.3.2", 16 | "dedent": "^0.7.0", 17 | "diff": "^5.2.0", 18 | "execa": "^9.1.0", 19 | "glob": "^10.4.1", 20 | "ini": "^4.1.3", 21 | "kolorist": "^1.7.0", 22 | "ollama": "^0.5.1", 23 | "openai": "^4.47.1", 24 | "playwright": "^1.44.1", 25 | "probe-image-size": "^7.2.3", 26 | "sharp": "^0.33.4" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/BuilderIO/micro-agent" 31 | }, 32 | "files": [ 33 | "dist" 34 | ], 35 | "bin": { 36 | "micro-agent": "./dist/cli.mjs", 37 | "ma": "./dist/cli.mjs" 38 | }, 39 | "scripts": { 40 | "test": "vitest run --exclude src/tests/integration", 41 | "test:integration": "vitest run src/tests/integration --exclude src/tests/integration/add.test.ts --poolOptions.threads.singleThread", 42 | "test:all": "vitest run", 43 | "start": "jiti ./src/cli.ts", 44 | "lint:fix": "prettier --write . && eslint --fix", 45 | "lint": "prettier --check . && eslint", 46 | "typecheck": "tsc", 47 | "build": "pkgroll", 48 | "release:patch": "npm run build && standard-version --release-as patch git push --follow-tags origin main && npm publish", 49 | "standard-version:release": "standard-version", 50 | "standard-version:release:minor": "standard-version --release-as minor", 51 | "standard-version:release:major": "standard-version --release-as major", 52 | "standard-version:release:patch": "standard-version --release-as patch", 53 | "postinstall": "npx playwright install", 54 | "prepare": "husky install" 55 | }, 56 | "devDependencies": { 57 | "@types/dedent": "^0.7.0", 58 | "@types/ini": "^1.3.31", 59 | "@types/node": "^18.15.11", 60 | "@typescript-eslint/eslint-plugin": "^5.57.1", 61 | "@typescript-eslint/parser": "^5.57.1", 62 | "eslint": "^8.38.0", 63 | "eslint-plugin-unused-imports": "^2.0.0", 64 | "husky": "^8.0.0", 65 | "jiti": "^1.21.3", 66 | "pkgroll": "^1.11.1", 67 | "prettier": "^2.8.8", 68 | "standard-version": "^9.5.0", 69 | "typescript": "^4.9.5", 70 | "vitest": "^1.6.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/helpers/get-test-command.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { getDependencyFile, getDependencyFileName } from './dependency-files'; 3 | import { getSimpleCompletion } from './llm'; 4 | import { removeBackticks } from './remove-backticks'; 5 | 6 | export async function getTestCommand({ 7 | testFilePath, 8 | }: { 9 | testFilePath: string; 10 | }) { 11 | const defaultTestCommand = `npm test -- ${ 12 | testFilePath.split('/').pop()!.split('.')[0] 13 | }`; 14 | 15 | const testFileExtension = testFilePath.split('.').pop(); 16 | const dependencyFileName = getDependencyFileName(testFileExtension); 17 | const dependencyFileContent = await getDependencyFile( 18 | process.cwd(), 19 | testFileExtension 20 | ); 21 | if (!dependencyFileContent) { 22 | return defaultTestCommand; 23 | } 24 | 25 | const suggestion = removeBackticks( 26 | await getSimpleCompletion({ 27 | messages: [ 28 | { 29 | role: 'system', 30 | content: 31 | 'You take a prompt and return a single line shell command and nothing else', 32 | }, 33 | { 34 | role: 'user', 35 | content: dedent` 36 | Here is my ${dependencyFileName}. I want to run a single command to execute the tests. The tests should not run in watch mode. 37 | If there is a test script in the ${dependencyFileName}, use that script. For example, \`npm test\`. 38 | The command should filter and run the specific test file at \`${testFilePath}\`. For example, \`npm test -- ${ 39 | testFilePath.split('/').pop()!.split('.')[0] 40 | }\`. 41 | 42 | Here are sample test commands without watch mode that work for some popular testing libraries: 43 | - Jest: \`npm test -- ${ 44 | testFilePath.split('/').pop()!.split('.')[0] 45 | } --no-watch \` 46 | - Vitest: \`npm test -- ${ 47 | testFilePath.split('/').pop()!.split('.')[0] 48 | } --run \` 49 | - minitest: \`rails test ${ 50 | testFilePath.split('/').pop()!.split('.')[0] 51 | }\` 52 | - rspec: \`rspec ${testFilePath.split('/').pop()!.split('.')[0]}\` 53 | - pytest: \`pytest ${testFilePath.split('/').pop()!.split('.')[0]}\` 54 | - unittest: \`python -m unittest ${ 55 | testFilePath.split('/').pop()!.split('.')[0] 56 | }\` 57 | 58 | <${dependencyFileName.replace('.', '-')}> 59 | ${dependencyFileContent} 60 | 61 | 62 | If no testing libraries are found in the package.json, use \`npx vitest run ${ 63 | testFilePath.split('/').pop()!.split('.')[0] 64 | }\` as a fallback. 65 | `, 66 | }, 67 | ], 68 | }) 69 | ); 70 | 71 | return suggestion || defaultTestCommand; 72 | } 73 | -------------------------------------------------------------------------------- /src/tests/ternary/simplify.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | function simplify(inputCode: string): string { 4 | const sourceFile = ts.createSourceFile( 5 | 'temp.ts', 6 | inputCode, 7 | ts.ScriptTarget.Latest, 8 | true, 9 | ts.ScriptKind.TS 10 | ); 11 | 12 | interface ConditionEvaluation { 13 | condition: string; 14 | whenTrue: string; 15 | } 16 | 17 | function collectTernaryConditions(node: ts.Node): ConditionEvaluation[] { 18 | const conditions: ConditionEvaluation[] = []; 19 | 20 | function traverse(node: ts.Node) { 21 | if (ts.isConditionalExpression(node)) { 22 | const condition = node.condition.getText().trim(); 23 | const whenTrue = node.whenTrue.getText().trim(); 24 | conditions.push({ condition, whenTrue }); 25 | traverse(node.whenFalse); 26 | } else { 27 | ts.forEachChild(node, traverse); 28 | } 29 | } 30 | 31 | traverse(node); 32 | return conditions; 33 | } 34 | 35 | function simplifyConditions( 36 | conditions: ConditionEvaluation[] 37 | ): string | null { 38 | const conditionMap: Record> = {}; 39 | const resultMap: Record = {}; 40 | const finalResultSet = new Set(); 41 | 42 | conditions.forEach(({ condition, whenTrue }) => { 43 | const conditionParts = condition.split(' && '); 44 | 45 | if (!resultMap[whenTrue]) { 46 | resultMap[whenTrue] = []; 47 | } 48 | resultMap[whenTrue].push(condition); 49 | finalResultSet.add(whenTrue); 50 | 51 | conditionParts.forEach((part) => { 52 | const [key, val] = part 53 | .split(' === ') 54 | .map((s) => s.trim().replace(/['"]/g, '')); 55 | if (!conditionMap[key]) { 56 | conditionMap[key] = new Set(); 57 | } 58 | conditionMap[key].add(val); 59 | }); 60 | }); 61 | 62 | if (finalResultSet.size === 1) { 63 | return Array.from(finalResultSet)[0]; 64 | } 65 | 66 | let primaryKey = ''; 67 | for (const key in conditionMap) { 68 | if (conditionMap[key].size > 1) { 69 | primaryKey = key; 70 | break; 71 | } 72 | } 73 | 74 | if (!primaryKey) { 75 | return null; 76 | } 77 | 78 | const primaryResult = Array.from(finalResultSet).find( 79 | (res) => res !== resultMap[primaryKey][0] 80 | ); 81 | const primaryConditions = Array.from(conditionMap[primaryKey]) 82 | .map((val) => `${primaryKey} === '${val}'`) 83 | .join(' || '); 84 | 85 | return `${primaryConditions} ? ${resultMap[primaryKey][0]} : ${primaryResult}`; 86 | } 87 | 88 | const ternaryConditions = collectTernaryConditions(sourceFile); 89 | const conditionAnalysis = simplifyConditions(ternaryConditions); 90 | 91 | if (conditionAnalysis) { 92 | return conditionAnalysis; 93 | } 94 | 95 | return inputCode; 96 | } 97 | 98 | export { simplify }; 99 | -------------------------------------------------------------------------------- /src/tests/ternary/simplify.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { simplify } from './simplify'; 3 | 4 | test.skip('simplifies ternaries correctly', () => { 5 | expect( 6 | simplify(` 7 | type === 'Default' && status === 'Default' 8 | ? '71px' 9 | : type === 'With Icon' && status === 'Default' 10 | ? '79px' 11 | : type === 'With Icon' && status === 'Neutral' 12 | ? '79px' 13 | : type === 'With Icon' && status === 'Active' 14 | ? '79px' 15 | : type === 'With Icon' && status === 'Alert' 16 | ? '79px' 17 | : type === 'With Icon' && status === 'Caution' 18 | ? '79px' 19 | : type === 'Default' && status === 'Neutral' 20 | ? '71px' 21 | : type === 'Default' && status === 'Active' 22 | ? '71px' 23 | : type === 'Default' && status === 'Alert' 24 | ? '71px' 25 | : '71px' 26 | `) 27 | ).toBe(`type === 'With Icon' ? '79px' : '71px'`); 28 | 29 | expect( 30 | simplify(` 31 | type === "Default" && status === "Default" 32 | ? "start" 33 | : type === "Has Label" && status === "Default" 34 | ? "stretch" 35 | : type === "Has Label" && status === "Neutral" 36 | ? "stretch" 37 | : type === "Has Label" && status === "Active" 38 | ? "stretch" 39 | : type === "Has Label" && status === "Alert" 40 | ? "stretch" 41 | : type === "Has Label" && status === "Caution" 42 | ? "stretch" 43 | : type === "Default" && status === "Neutral" 44 | ? "start" 45 | : type === "Default" && status === "Active" 46 | ? "start" 47 | : type === "Default" && status === "Alert" 48 | ? "start" 49 | : "start" 50 | `) 51 | ).toBe(`type === 'With Icon' ? 'stretch' : 'start'`); 52 | expect( 53 | simplify(` 54 | type === "Baseline" && size === "4px" && status === "Default" 55 | ? "400" 56 | : type === "Baseline" && size === "4px" && status === "Complete" 57 | ? undefined 58 | : type === "Baseline" && size === "4px" && status === "Error" 59 | ? undefined 60 | : type === "Baseline" && size === "8px" && status === "Complete" 61 | ? undefined 62 | : type === "Baseline" && size === "8px" && status === "Default" 63 | ? "400" 64 | : type === "Baseline" && size === "8px" && status === "Error" 65 | ? undefined 66 | : type === "Detailed" && size === "8px" && status === "Complete" 67 | ? undefined 68 | : type === "Detailed" && size === "4px" && status === "Default" 69 | ? undefined 70 | : type === "Detailed" && 71 | size === "4px" && 72 | status === "Complete (alternate)" 73 | ? undefined 74 | : type === "Detailed" && size === "8px" && status === "Default" 75 | ? undefined 76 | : type === "Detailed" && 77 | size === "8px" && 78 | status === "Complete (alternate)" 79 | ? undefined 80 | : type === "Detailed" && size === "4px" && status === "Complete" 81 | ? undefined 82 | : type === "Detailed" && size === "8px" && status === "Error" 83 | ? undefined 84 | : undefined 85 | `) 86 | ).toBe( 87 | `type === 'Baseline' && ((size === '4px' || size === '8px') && status === 'Default') ? '400' : undefined` 88 | ); 89 | }); 90 | -------------------------------------------------------------------------------- /src/tests/integration/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { execaCommand } from 'execa'; 2 | import { readFile, writeFile } from 'fs/promises'; 3 | import { afterEach, describe, expect, it } from 'vitest'; 4 | import { removeBackticks } from '../../helpers/remove-backticks'; 5 | 6 | const integrationTestPath = 'src/tests/integration'; 7 | 8 | describe('cli', () => { 9 | it('should run with mock LLM', async () => { 10 | // Write the test file using the mock LLM record 11 | const mockLlmRecordFile = 'test/fixtures/add.json'; 12 | const mockLlmRecordFileContents = await readFile( 13 | mockLlmRecordFile, 14 | 'utf-8' 15 | ); 16 | const jsonLlmRecording = JSON.parse(mockLlmRecordFileContents.toString()); 17 | 18 | const testContents = jsonLlmRecording.completions[1].output; 19 | await writeFile( 20 | `${integrationTestPath}/add.test.ts`, 21 | removeBackticks(testContents) 22 | ); 23 | 24 | // Execute the CLI command 25 | const result = await execaCommand( 26 | `USE_MOCK_LLM=true MOCK_LLM_RECORD_FILE=test/fixtures/add.json jiti ./src/cli.ts ${integrationTestPath}/add.ts -f ${integrationTestPath}/add.test.ts -t "npm run test:all -- add"`, 27 | { 28 | input: '\x03', 29 | shell: process.env.SHELL || true, 30 | } 31 | ); 32 | 33 | const output = result.stdout; 34 | 35 | // Check the output 36 | expect(output).toContain('add is not a function'); 37 | expect(output).toContain('Generating code...'); 38 | expect(output).toContain('Updated code'); 39 | expect(output).toContain('Running tests...'); 40 | expect(output).toContain(`6 passed`); 41 | expect(output).toContain('All tests passed!'); 42 | }); 43 | 44 | it('should work on spec file', async () => { 45 | await writeFile( 46 | `${integrationTestPath}/add.test.ts`, 47 | "import { test, expect } from 'vitest';\n\ntest('pass', () => {\nexpect(true)\n});" 48 | ); 49 | 50 | // Write the test file using the mock LLM record 51 | const mockLlmRecordFile = 'test/fixtures/add.json'; 52 | const mockLlmRecordFileContents = await readFile( 53 | mockLlmRecordFile, 54 | 'utf-8' 55 | ); 56 | const jsonLlmRecording = JSON.parse(mockLlmRecordFileContents.toString()); 57 | 58 | const testContents = jsonLlmRecording.completions[1].output; 59 | await writeFile( 60 | `${integrationTestPath}/add.spec.ts`, 61 | removeBackticks(testContents) 62 | ); 63 | 64 | // Execute the CLI command 65 | const result = await execaCommand( 66 | `USE_MOCK_LLM=true MOCK_LLM_RECORD_FILE=test/fixtures/add.json jiti ./src/cli.ts ${integrationTestPath}/add.ts -f ${integrationTestPath}/add.spec.ts -t "npm run test:all -- add"`, 67 | { 68 | input: '\x03', 69 | shell: process.env.SHELL || true, 70 | } 71 | ); 72 | 73 | const output = result.stdout; 74 | 75 | // Check the output 76 | expect(output).toContain('add is not a function'); 77 | expect(output).toContain('Generating code...'); 78 | expect(output).toContain('Updated code'); 79 | expect(output).toContain('Running tests...'); 80 | expect(output).toContain(`7 passed`); 81 | expect(output).toContain('All tests passed!'); 82 | }); 83 | 84 | afterEach(async () => { 85 | await writeFile(`${integrationTestPath}/add.ts`, ''); 86 | await writeFile(`${integrationTestPath}/add.test.ts`, ''); 87 | await writeFile( 88 | `${integrationTestPath}/add.spec.ts`, 89 | "import {describe, test} from 'vitest'\n describe('spec', () => {test.todo('please pass');});" 90 | ); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/helpers/dependency-files.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { getDependencyFile, getDependencyFileName } from './dependency-files'; 3 | import path from 'path'; 4 | 5 | const mocks = vi.hoisted(() => { 6 | return { 7 | readFile: vi.fn(), 8 | fileExists: vi.fn(), 9 | }; 10 | }); 11 | 12 | vi.mock('fs/promises', () => { 13 | return { 14 | readFile: mocks.readFile, 15 | }; 16 | }); 17 | 18 | vi.mock('./file-exists', () => { 19 | return { 20 | fileExists: mocks.fileExists, 21 | }; 22 | }); 23 | 24 | describe('getDependencyFile', () => { 25 | it('should return the contents of package.json for node', async () => { 26 | const packageJsonContent = JSON.stringify({ 27 | name: 'example', 28 | version: '1.0.0', 29 | }); 30 | mocks.readFile.mockResolvedValueOnce(packageJsonContent); 31 | mocks.fileExists.mockResolvedValue(true); 32 | 33 | const result = await getDependencyFile('', 'ts'); 34 | expect(result).toBe(packageJsonContent); 35 | expect(mocks.readFile).toHaveBeenCalledWith('package.json', 'utf8'); 36 | }); 37 | 38 | it('should return the contents of requirements.txt for python', async () => { 39 | const requirementsTxtContent = 'example-package==1.0.0'; 40 | mocks.readFile.mockResolvedValueOnce(requirementsTxtContent); 41 | mocks.fileExists.mockResolvedValue(true); 42 | 43 | const result = await getDependencyFile('', 'py'); 44 | expect(result).toBe(requirementsTxtContent); 45 | expect(mocks.readFile).toHaveBeenCalledWith('requirements.txt', 'utf8'); 46 | }); 47 | 48 | it('should return the contents of Gemfile for ruby', async () => { 49 | const gemfileContent = "gem 'rails', '5.0.0'"; 50 | mocks.readFile.mockResolvedValueOnce(gemfileContent); 51 | mocks.fileExists.mockResolvedValue(true); 52 | 53 | const result = await getDependencyFile('', 'rb'); 54 | expect(result).toBe(gemfileContent); 55 | expect(mocks.readFile).toHaveBeenCalledWith('Gemfile', 'utf8'); 56 | }); 57 | 58 | it('should check all three dependency files if no language is provided', async () => { 59 | mocks.fileExists.mockReset(); 60 | mocks.fileExists.mockResolvedValue(false); 61 | 62 | const result = await getDependencyFile('/src'); 63 | expect(mocks.fileExists).toHaveBeenCalledTimes(3); 64 | expect(mocks.fileExists).toHaveBeenCalledWith( 65 | path.join('/src', 'package.json') 66 | ); 67 | expect(mocks.fileExists).toHaveBeenCalledWith( 68 | path.join('/src', 'requirements.txt') 69 | ); 70 | expect(mocks.fileExists).toHaveBeenCalledWith(path.join('/src', 'Gemfile')); 71 | }); 72 | 73 | it('should return null if package.json file does not exist', async () => { 74 | mocks.fileExists.mockResolvedValue(false); 75 | 76 | await expect(getDependencyFile()).resolves.toBeNull(); 77 | }); 78 | 79 | it('should return null if requirements.txt does not exist for python', async () => { 80 | mocks.fileExists.mockResolvedValue(false); 81 | 82 | await expect(getDependencyFile(process.cwd(), 'py')).resolves.toBeNull(); 83 | }); 84 | 85 | it('should return null if Gemfile does not exist for ruby', async () => { 86 | mocks.fileExists.mockResolvedValue(false); 87 | 88 | await expect(getDependencyFile(process.cwd(), 'rb')).resolves.toBeNull(); 89 | }); 90 | }); 91 | 92 | describe('getDependencyFileName', () => { 93 | it('should return package.json for node', () => { 94 | const result = getDependencyFileName(); 95 | expect(result).toBe('package.json'); 96 | }); 97 | 98 | it('should return requirements.txt for python', () => { 99 | const result = getDependencyFileName('py'); 100 | expect(result).toBe('requirements.txt'); 101 | }); 102 | 103 | it('should return Gemfile for ruby', () => { 104 | const result = getDependencyFileName('rb'); 105 | expect(result).toBe('Gemfile'); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.1.5](https://github.com/BuilderIO/micro-agent/compare/v0.1.4...v0.1.5) (2024-07-10) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * suppress punnycode warnings ([ab9f1b5](https://github.com/BuilderIO/micro-agent/commit/ab9f1b5af52d769e020104915cf052c1b045ad0c)) 11 | 12 | ### [0.1.4](https://github.com/BuilderIO/micro-agent/compare/v0.1.3...v0.1.4) (2024-06-23) 13 | 14 | 15 | ### Features 16 | 17 | * add anthropic key to interactive config UI ([af0cd1f](https://github.com/BuilderIO/micro-agent/commit/af0cd1f3f49077a3a5d3e8935c614e16d1ee745c)) 18 | 19 | ### [0.1.3](https://github.com/BuilderIO/micro-agent/compare/v0.1.2...v0.1.3) (2024-06-23) 20 | 21 | ### [0.1.2](https://github.com/BuilderIO/micro-agent/compare/v0.1.1...v0.1.2) (2024-06-20) 22 | 23 | ### [0.1.1](https://github.com/BuilderIO/micro-agent/compare/v0.1.0...v0.1.1) (2024-06-20) 24 | 25 | ### [0.0.49](https://github.com/BuilderIO/micro-agent/compare/v0.0.48...v0.0.49) (2024-06-18) 26 | 27 | ### [0.0.48](https://github.com/BuilderIO/micro-agent/compare/v0.0.47...v0.0.48) (2024-06-17) 28 | 29 | ### [0.0.47](https://github.com/BuilderIO/micro-agent/compare/v0.0.46...v0.0.47) (2024-06-14) 30 | 31 | ### Bug Fixes 32 | 33 | - move sharp to be async so it doesn't throw errors when not needed ([c86f371](https://github.com/BuilderIO/micro-agent/commit/c86f3711b871099d5b6d8b06777a6477e9463468)) 34 | 35 | ### [0.0.46](https://github.com/BuilderIO/micro-agent/compare/v0.0.45...v0.0.46) (2024-06-13) 36 | 37 | ### [0.0.45](https://github.com/BuilderIO/micro-agent/compare/v0.0.44...v0.0.45) (2024-06-13) 38 | 39 | ### Features 40 | 41 | - abort if the same error happens on repeat ([49bb033](https://github.com/BuilderIO/micro-agent/commit/49bb03373c9c8f8a309014058fab6aadb44be585)) 42 | 43 | ### [0.0.44](https://github.com/BuilderIO/micro-agent/compare/v0.0.43...v0.0.44) (2024-06-13) 44 | 45 | ### [0.0.43](https://github.com/BuilderIO/micro-agent/compare/v0.0.42...v0.0.43) (2024-06-12) 46 | 47 | ### Features 48 | 49 | - detect if in a valid project and generate one if not ([b8a5731](https://github.com/BuilderIO/micro-agent/commit/b8a5731e82fd5541c738cdf094870fbac035386b)) 50 | 51 | ### [0.0.42](https://github.com/BuilderIO/micro-agent/compare/v0.0.41...v0.0.42) (2024-06-12) 52 | 53 | ### Bug Fixes 54 | 55 | - default to vitest if no existing testing library ([1a71f6a](https://github.com/BuilderIO/micro-agent/commit/1a71f6a2c123ae48022b9ff66e13a80c03a6d301)) 56 | 57 | ### [0.0.41](https://github.com/BuilderIO/micro-agent/compare/v0.0.40...v0.0.41) (2024-06-12) 58 | 59 | ### [0.0.40](https://github.com/BuilderIO/micro-agent/compare/v0.0.39...v0.0.40) (2024-06-12) 60 | 61 | ### [0.0.39](https://github.com/BuilderIO/micro-agent/compare/v0.0.38...v0.0.39) (2024-06-12) 62 | 63 | ### [0.0.37](https://github.com/BuilderIO/micro-agent/compare/v0.0.36...v0.0.37) (2024-06-12) 64 | 65 | ### [0.0.36](https://github.com/BuilderIO/micro-agent/compare/v0.0.35...v0.0.36) (2024-06-12) 66 | 67 | ### [0.0.34](https://github.com/BuilderIO/micro-agent/compare/v0.0.33...v0.0.34) (2024-06-11) 68 | 69 | ### [0.0.32](https://github.com/BuilderIO/micro-agent/compare/v0.0.31...v0.0.32) (2024-06-11) 70 | 71 | ### [0.0.30](https://github.com/BuilderIO/micro-agent/compare/v0.0.29...v0.0.30) (2024-06-10) 72 | 73 | ### [0.0.29](https://github.com/BuilderIO/micro-agent/compare/v0.0.28...v0.0.29) (2024-06-10) 74 | 75 | ### [0.0.28](https://github.com/BuilderIO/micro-agent/compare/v0.0.27...v0.0.28) (2024-06-10) 76 | 77 | ### [0.0.26](https://github.com/BuilderIO/micro-agent/compare/v0.0.25...v0.0.26) (2024-06-06) 78 | 79 | ### [0.0.24](https://github.com/BuilderIO/micro-agent/compare/v0.0.23...v0.0.24) (2024-06-06) 80 | 81 | ### [0.0.22](https://github.com/BuilderIO/micro-agent/compare/v0.0.21...v0.0.22) (2024-06-06) 82 | 83 | ### [0.0.20](https://github.com/BuilderIO/micro-agent/compare/v0.0.19...v0.0.20) (2024-06-06) 84 | 85 | ### 0.0.17 (2024-06-06) 86 | 87 | ### Features 88 | 89 | - initial feature commit ([27cdcf1](https://github.com/BuilderIO/micro-agent/commit/27cdcf1b522bd4caad61d9043c2ca24ae751ab21)) 90 | -------------------------------------------------------------------------------- /src/tests/ternary/simplify.prompt.md: -------------------------------------------------------------------------------- 1 | Write a function called "simplify" that converts a complex and redundant ternary to a simpler one using the typescript parser. 2 | 3 | It takes code as a string and converts it to new code as a string. 4 | 5 | For example, this 6 | 7 | ```ts 8 | type === 'Default' && status === 'Default' 9 | ? '71px' 10 | : type === 'With Icon' && status === 'Default' 11 | ? '79px' 12 | : type === 'With Icon' && status === 'Neutral' 13 | ? '79px' 14 | : type === 'With Icon' && status === 'Active' 15 | ? '79px' 16 | : type === 'With Icon' && status === 'Alert' 17 | ? '79px' 18 | : type === 'With Icon' && status === 'Caution' 19 | ? '79px' 20 | : type === 'Default' && status === 'Neutral' 21 | ? '71px' 22 | : type === 'Default' && status === 'Active' 23 | ? '71px' 24 | : type === 'Default' && status === 'Alert' 25 | ? '71px' 26 | : '71px' 27 | ``` 28 | 29 | should convert to this 30 | 31 | ```ts 32 | type === 'With Icon' ? '79px' : '71px' 33 | ``` 34 | 35 | Note that these ternaries always use enum values that are exhaustive. For instance, in the above example, the `type` can only be `'Default'` or `'With Icon'` and the `status` can only be `'Default'`, `'Neutral'`, `'Active'`, `'Alert'`, or `'Caution'`, because that is what is used in the ternary. Given that `status` never changes the output, the `type` is the only thing that matters, and `status` can be removed from the ternary completley. Assume this for all ternaries passed, if their values never change the output, they can be removed. 36 | 37 | Another examples is this 38 | 39 | ```ts 40 | foo === 'Yo' && status === 'Default' 41 | ? 'start' 42 | : foo === 'Some Value' && status === 'Default' 43 | ? 'stretch' 44 | : foo === 'Some Value' && status === 'Neutral' 45 | ? 'stretch' 46 | : foo === 'Some Value' && status === 'Active' 47 | ? 'stretch' 48 | : foo === 'Some Value' && status === 'Alert' 49 | ? 'stretch' 50 | : foo === 'Some Value' && status === 'Caution' 51 | ? 'stretch' 52 | : foo === 'Yo' && status === 'Neutral' 53 | ? 'start' 54 | : foo === 'Yo' && status === 'Active' 55 | ? 'start' 56 | : foo === 'Yo' && status === 'Alert' 57 | ? 'start' 58 | : 'start' 59 | ``` 60 | 61 | Should convert to this 62 | 63 | ```ts 64 | foo === 'With Icon' ? 'stretch' : 'start' 65 | ``` 66 | 67 | In the above case, also notice that `status` never changes the output, so it can be removed completely. Again, assume this for all ternaries passed, if their values never change the output, they can be removed. 68 | 69 | And one more example is this: 70 | 71 | ```ts 72 | type === 'Baseline' && size === '4px' && status === 'Default' 73 | ? '400' 74 | : type === 'Baseline' && size === '4px' && status === 'Complete' 75 | ? undefined 76 | : type === 'Baseline' && size === '4px' && status === 'Error' 77 | ? undefined 78 | : type === 'Baseline' && size === '8px' && status === 'Complete' 79 | ? undefined 80 | : type === 'Baseline' && size === '8px' && status === 'Default' 81 | ? '400' 82 | : type === 'Baseline' && size === '8px' && status === 'Error' 83 | ? undefined 84 | : type === 'Detailed' && size === '8px' && status === 'Complete' 85 | ? undefined 86 | : type === 'Detailed' && size === '4px' && status === 'Default' 87 | ? undefined 88 | : type === 'Detailed' && size === '4px' && status === 'Complete (alternate)' 89 | ? undefined 90 | : type === 'Detailed' && size === '8px' && status === 'Default' 91 | ? undefined 92 | : type === 'Detailed' && size === '8px' && status === 'Complete (alternate)' 93 | ? undefined 94 | : type === 'Detailed' && size === '4px' && status === 'Complete' 95 | ? undefined 96 | : type === 'Detailed' && size === '8px' && status === 'Error' 97 | ? undefined 98 | : undefined 99 | ``` 100 | 101 | Should simplify to this: 102 | 103 | ```ts 104 | type === 'Baseline' && (size === '4px' || size === '8px') && status === 'Default' ? '400' : undefined 105 | ``` 106 | 107 | This should work with any ternary provided in this format, where you have an exhaustive list of values and their results. 108 | 109 | You need to find the simplest final output for any ternary like this given. 110 | -------------------------------------------------------------------------------- /src/helpers/test.ts: -------------------------------------------------------------------------------- 1 | import { ExecaError, execaCommand } from 'execa'; 2 | import { gray, green, red } from 'kolorist'; 3 | import { RunOptions, createCommandString } from './run'; 4 | import { outro } from '@clack/prompts'; 5 | 6 | type Fail = { 7 | type: 'fail'; 8 | message: string; 9 | }; 10 | 11 | type Success = { 12 | type: 'success'; 13 | }; 14 | 15 | type Result = Fail | Success; 16 | 17 | const prevTestFailures: string[] = []; 18 | 19 | // Check if the last n failures had the same message 20 | const hasFailedNTimesWithTheSameMessage = (message: string, n = 4) => { 21 | if (prevTestFailures.length < n) { 22 | return false; 23 | } 24 | 25 | // If the last n failures had the same message, return true 26 | return prevTestFailures 27 | .slice(prevTestFailures.length - n, prevTestFailures.length) 28 | .every((msg) => msg === message); 29 | }; 30 | 31 | export const fail = (message: string) => { 32 | return { 33 | type: 'fail', 34 | message, 35 | } as const; 36 | }; 37 | 38 | const testFail = (message: string, options: RunOptions) => { 39 | prevTestFailures.push(message); 40 | if (hasFailedNTimesWithTheSameMessage(message)) { 41 | if (!options.addedLogs) { 42 | options.addedLogs = true; 43 | return fail('Repeated test failures detected. Adding logs to the code.'); 44 | } else { 45 | outro( 46 | red( 47 | 'Your test command is failing with the same error several times. Please make sure your test command is correct. Aborting...' 48 | ) 49 | ); 50 | console.log( 51 | `${green('To continue, run:')}\n${gray( 52 | `${createCommandString(options)}` 53 | )}\n` 54 | ); 55 | process.exit(1); 56 | } 57 | } 58 | return fail(message); 59 | }; 60 | 61 | export const success = () => { 62 | return { 63 | type: 'success', 64 | } as const; 65 | }; 66 | 67 | export const isFail = (result: unknown): result is Fail => { 68 | return (result as any)?.type === 'fail'; 69 | }; 70 | 71 | export function formatMessage(message: string): string { 72 | return gray(message.replaceAll('\n', '\n' + '│ ')); 73 | } 74 | 75 | export const isInvalidCommand = (output: string) => { 76 | return ( 77 | output.includes('command not found:') || 78 | output.includes('command_not_found:') || 79 | output.includes('npm ERR! Missing script:') 80 | ); 81 | }; 82 | 83 | const exitOnInvalidCommand = (output: string) => { 84 | if (isInvalidCommand(output)) { 85 | outro(red('Your test command is invalid. Please try again.')); 86 | process.exit(1); 87 | } 88 | }; 89 | 90 | export async function test(options: RunOptions): Promise { 91 | let timeout: NodeJS.Timeout; 92 | const timeoutSeconds = 20; 93 | const resetTimer = () => { 94 | clearTimeout(timeout); 95 | timeout = setTimeout(() => { 96 | console.log('\n'); 97 | outro( 98 | red( 99 | `No test output for ${timeoutSeconds} seconds. Is your test command running in watch mode? If so, make sure to use a test command that exits after running the tests.` 100 | ) 101 | ); 102 | 103 | process.exit(0); 104 | }, timeoutSeconds * 1000); 105 | }; 106 | const endTimer = () => { 107 | clearTimeout(timeout); 108 | }; 109 | 110 | resetTimer(); 111 | 112 | const testScript = options.testCommand; 113 | try { 114 | const result = execaCommand(testScript, { 115 | shell: process.env.SHELL || true, 116 | }); 117 | result.stderr.on('data', (data) => { 118 | process.stderr.write(formatMessage(data.toString())); 119 | resetTimer(); 120 | }); 121 | result.stdout.on('data', (data) => { 122 | process.stdout.write(formatMessage(data.toString())); 123 | resetTimer(); 124 | }); 125 | 126 | const final = await result; 127 | if (final.stderr) { 128 | exitOnInvalidCommand(final.stderr); 129 | } 130 | process.stdout.write('\n'); 131 | endTimer(); 132 | 133 | if (final.failed) { 134 | return testFail(final.stderr, options); 135 | } 136 | return success(); 137 | } catch (error: any) { 138 | process.stdout.write('\n'); 139 | endTimer(); 140 | if (error instanceof ExecaError) { 141 | exitOnInvalidCommand(error.stderr || error.message); 142 | return testFail(error.stderr || error.message, options); 143 | } 144 | return testFail(error.message, options); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/tests/angular-parser/parse.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | function isInputDecorator(decorator: ts.Decorator): boolean { 4 | if (ts.isCallExpression(decorator.expression)) { 5 | const callExpression = decorator.expression; 6 | if (ts.isIdentifier(callExpression.expression)) { 7 | return callExpression.expression.getText() === 'Input'; 8 | } 9 | } 10 | return false; 11 | } 12 | 13 | function getTypeText(type: ts.TypeNode | undefined): string { 14 | if (!type) { 15 | return 'any'; 16 | } 17 | const text = type.getText().trim(); 18 | return text.includes('|') ? 'string' : text; 19 | } 20 | 21 | function extractSelectorFromComponentDecorator( 22 | componentNode: ts.ClassDeclaration 23 | ): string | undefined { 24 | for (const decorator of ts.getDecorators(componentNode) ?? []) { 25 | if (ts.isCallExpression(decorator.expression)) { 26 | const callExpression = decorator.expression; 27 | if ( 28 | callExpression.arguments.length && 29 | ts.isObjectLiteralExpression(callExpression.arguments[0]) 30 | ) { 31 | const properties = callExpression.arguments[0].properties; 32 | for (const prop of properties) { 33 | if ( 34 | ts.isPropertyAssignment(prop) && 35 | ts.isIdentifier(prop.name) && 36 | prop.name.text === 'selector' 37 | ) { 38 | if (ts.isStringLiteral(prop.initializer)) { 39 | return prop.initializer.text; 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | return undefined; 47 | } 48 | 49 | function extractEnumOptions(componentString: string): Record { 50 | const enumOptions: Record = {}; 51 | const sourceFile = ts.createSourceFile( 52 | 'temp.ts', 53 | componentString, 54 | ts.ScriptTarget.Latest, 55 | true 56 | ); 57 | 58 | ts.forEachChild(sourceFile, (node) => { 59 | if ( 60 | ts.isTypeAliasDeclaration(node) && 61 | ts.isUnionTypeNode(node.type) && 62 | node.type.types.every( 63 | (t) => 64 | ts.isLiteralTypeNode(t) && 65 | ts.isStringLiteral((t as ts.LiteralTypeNode).literal) 66 | ) 67 | ) { 68 | enumOptions[node.name.text] = node.type.types.map( 69 | (t) => ((t as ts.LiteralTypeNode).literal as ts.StringLiteral).text 70 | ); 71 | } 72 | }); 73 | 74 | return enumOptions; 75 | } 76 | 77 | export function parse(componentString: string) { 78 | const enumOptions = extractEnumOptions(componentString); 79 | 80 | const sourceFile = ts.createSourceFile( 81 | 'temp.ts', 82 | componentString, 83 | ts.ScriptTarget.Latest, 84 | true 85 | ); 86 | 87 | const component: any = {}; 88 | 89 | ts.forEachChild(sourceFile, (node) => { 90 | if (ts.isClassDeclaration(node)) { 91 | const className = node.name?.getText(); 92 | if (className) { 93 | component.name = className; 94 | } 95 | 96 | const selector = extractSelectorFromComponentDecorator(node); 97 | if (selector) { 98 | component.selector = selector; 99 | } 100 | 101 | component.inputs = []; 102 | 103 | node.members.forEach((member) => { 104 | if (ts.isPropertyDeclaration(member)) { 105 | const decorators = ts.getDecorators(member); 106 | if (decorators) { 107 | decorators.forEach((decorator) => { 108 | if (isInputDecorator(decorator)) { 109 | const inputType = getTypeText(member.type); 110 | const input: any = { 111 | name: member.name.getText(), 112 | type: enumOptions[inputType] ? 'string' : inputType, 113 | }; 114 | if (enumOptions[inputType]) { 115 | input.enum = enumOptions[inputType]; 116 | } 117 | component.inputs.push(input); 118 | } 119 | }); 120 | } 121 | } 122 | }); 123 | } 124 | }); 125 | 126 | // Resolve typedefs used as input types 127 | const typeAliasMap: Record = {}; 128 | ts.forEachChild(sourceFile, (node) => { 129 | if (ts.isTypeAliasDeclaration(node) && node.type) { 130 | typeAliasMap[node.name.text] = node.type.getText(); 131 | } 132 | }); 133 | 134 | component.inputs.forEach((input: any) => { 135 | if (typeAliasMap[input.type]) { 136 | input.type = typeAliasMap[input.type]; 137 | } 138 | }); 139 | 140 | return component; 141 | } 142 | -------------------------------------------------------------------------------- /src/helpers/visual-test.ts: -------------------------------------------------------------------------------- 1 | import Anthropic from '@anthropic-ai/sdk'; 2 | import { RunOptions } from './run'; 3 | import { getConfig } from './config'; 4 | import { bufferToBase64Url, imageFilePathToBase64Url } from './base64'; 5 | import { findVisualFile } from './find-visual-file'; 6 | import { getScreenshot } from './get-screenshot'; 7 | import { formatMessage } from './test'; 8 | import dedent from 'dedent'; 9 | import { outputFile } from './output-file'; 10 | 11 | // use sharp to combine two images, putting them side by side 12 | const combineTwoImages = async (image1: string, image2: string) => { 13 | const { default: sharp } = await import('sharp'); 14 | const image1Buffer = Buffer.from(image1.split(',')[1], 'base64'); 15 | const image2Buffer = Buffer.from(image2.split(',')[1], 'base64'); 16 | 17 | const image1Sharp = sharp(image1Buffer); 18 | const image2Sharp = sharp(image2Buffer); 19 | 20 | const image1Metadata = await image1Sharp.metadata(); 21 | const image2Metadata = await image2Sharp.metadata(); 22 | 23 | const width = image1Metadata.width! + image2Metadata.width!; 24 | const height = Math.max(image1Metadata.height!, image2Metadata.height!); 25 | 26 | const combinedImage = sharp({ 27 | create: { 28 | width, 29 | height, 30 | channels: 4, 31 | background: { r: 255, g: 255, b: 255, alpha: 1 }, 32 | }, 33 | }); 34 | 35 | return ( 36 | combinedImage 37 | .composite([ 38 | { 39 | input: image1Buffer, 40 | top: 0, 41 | left: 0, 42 | }, 43 | { 44 | input: image2Buffer, 45 | top: 0, 46 | left: image1Metadata.width, 47 | }, 48 | ]) 49 | // .composite([ 50 | // { 51 | // input: await readFile('src/images/original-label.png'), 52 | // gravity: 'northwest', 53 | // }, 54 | // { 55 | // input: await readFile('src/images/my-version-label.png'), 56 | // gravity: 'northeast', 57 | // }, 58 | // ]) 59 | .png() 60 | .toBuffer() 61 | ); 62 | }; 63 | 64 | export async function visualTest(options: RunOptions) { 65 | const { ANTHROPIC_KEY } = await getConfig(); 66 | const anthropic = new Anthropic({ 67 | apiKey: ANTHROPIC_KEY, 68 | }); 69 | 70 | const filename = await findVisualFile(options); 71 | const designUrl = await imageFilePathToBase64Url(filename!); 72 | const screenshotUrl = bufferToBase64Url(await getScreenshot(options)); 73 | 74 | const composite = bufferToBase64Url( 75 | await combineTwoImages(designUrl, screenshotUrl) 76 | ); 77 | 78 | const debugImageOutputFolder = 'debug/images'; 79 | 80 | await outputFile( 81 | `${debugImageOutputFolder}/composite-image-url.txt`, 82 | composite 83 | ); 84 | 85 | const output = await new Promise((resolve, reject) => { 86 | let responseText = ''; 87 | process.stdout.write(formatMessage('\n')); 88 | anthropic.messages 89 | .stream({ 90 | model: 'claude-3-5-sonnet-20241022', 91 | max_tokens: 4096, 92 | messages: [ 93 | { 94 | role: 'user', 95 | content: [ 96 | { 97 | type: 'image', 98 | source: { 99 | type: 'base64', 100 | media_type: 'image/png', 101 | data: composite.split(',')[1], 102 | }, 103 | }, 104 | 105 | { 106 | type: 'text', 107 | text: dedent` 108 | here is a screenshot of some original code (left side of image) and my code trying to replicate it (right side of image) 109 | what did I get wrong? be incredibly specific, like which objects are missing or placed in the wrong places and where exactly they should be placed instead 110 | make it so that i could simply read what you say and update the code without any other visual and get it right. don't give me code, just words 111 | 112 | focus primarily on major layout differences. for instance, are all buttons, columns, etc in the right place? if not, be very precise about what should move exactly 113 | where. then, if the layout is perfect, focus on styling 114 | `, 115 | }, 116 | ], 117 | }, 118 | ], 119 | }) 120 | .on('text', (text) => { 121 | responseText += text; 122 | process.stderr.write(formatMessage(text)); 123 | }) 124 | .on('end', () => { 125 | process.stdout.write('\n'); 126 | resolve(responseText); 127 | }) 128 | .on('error', (error) => { 129 | reject(error); 130 | }); 131 | }); 132 | 133 | return output; 134 | } 135 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { cli } from 'cleye'; 2 | import { red } from 'kolorist'; 3 | import { version } from '../package.json'; 4 | import config from './commands/config'; 5 | import update from './commands/update'; 6 | import { commandName } from './helpers/constants'; 7 | import { handleCliError } from './helpers/error'; 8 | import { RunOptions, runAll } from './helpers/run'; 9 | import { interactiveMode } from './helpers/interactive-mode'; 10 | import { fileExists } from './helpers/file-exists'; 11 | import { outro } from '@clack/prompts'; 12 | import { isValidProject } from './helpers/validate-project'; 13 | import { invalidProjectWarningMessage } from './helpers/invalid-project-warning'; 14 | 15 | // Suppress punnycode warnings 16 | const originalEmit = process.emitWarning; 17 | process.emitWarning = function (...args) { 18 | const [warning] = args; 19 | const warningString = warning.toString(); 20 | // Ignore annoying "punnycode is deprecated" warning that comes 21 | // from one of our dependencies 22 | if (warningString.includes('punnycode')) return; 23 | return originalEmit.apply(process, args as any); 24 | }; 25 | 26 | cli( 27 | { 28 | name: commandName, 29 | version: version, 30 | parameters: ['[file path]'], 31 | flags: { 32 | prompt: { 33 | type: String, 34 | description: 'Prompt to run', 35 | alias: 'p', 36 | }, 37 | test: { 38 | type: String, 39 | description: 'The test script to run', 40 | alias: 't', 41 | }, 42 | testFile: { 43 | type: String, 44 | description: 'The test file to run', 45 | alias: 'f', 46 | }, 47 | maxRuns: { 48 | type: Number, 49 | description: 'The maximum number of runs to attempt', 50 | alias: 'm', 51 | }, 52 | thread: { 53 | type: String, 54 | description: 'Thread ID to resume', 55 | }, 56 | visual: { 57 | type: String, 58 | description: 59 | 'Visually diff a local screenshot with the result of this URL', 60 | alias: 'v', 61 | }, 62 | }, 63 | commands: [config, update], 64 | }, 65 | async (argv) => { 66 | const filePath = argv._.filePath; 67 | const fileExtension = filePath?.split('.').pop(); 68 | const testFileExtension = 69 | fileExtension && ['jsx', 'tsx'].includes(fileExtension as string) 70 | ? fileExtension?.replace('x', '') 71 | : fileExtension; 72 | 73 | const createReplacementFilePath = ( 74 | filePath: string, 75 | fileExtension: string, 76 | replacement: string 77 | ) => { 78 | return filePath.replace( 79 | new RegExp('\\.' + fileExtension + '$'), 80 | replacement 81 | ); 82 | }; 83 | 84 | const testFile = 85 | filePath && 86 | fileExtension && 87 | createReplacementFilePath( 88 | filePath, 89 | fileExtension, 90 | `.test.${testFileExtension}` 91 | ); 92 | const specFile = 93 | filePath && 94 | fileExtension && 95 | createReplacementFilePath( 96 | filePath, 97 | fileExtension, 98 | `.spec.${testFileExtension}` 99 | ); 100 | 101 | const testFileExists = async () => { 102 | if (testFile && (await fileExists(testFile))) { 103 | return testFile; 104 | } else if (specFile && (await fileExists(specFile))) { 105 | return specFile; 106 | } 107 | return undefined; 108 | }; 109 | 110 | const testFilePath = argv.flags.testFile || (await testFileExists()) || ''; 111 | const promptFilePath = 112 | argv.flags.prompt || 113 | filePath?.replace(new RegExp('\\.' + fileExtension + '$'), '.prompt.md'); 114 | 115 | const runOptions: RunOptions = { 116 | outputFile: filePath!, 117 | promptFile: promptFilePath || '', 118 | testCommand: argv.flags.test || '', 119 | testFile: testFilePath, 120 | lastRunError: '', 121 | maxRuns: argv.flags.maxRuns, 122 | threadId: argv.flags.thread || '', 123 | visual: argv.flags.visual || '', 124 | }; 125 | try { 126 | if (!argv._.filePath || !argv.flags.test) { 127 | const isValidproject = await isValidProject(); 128 | 129 | if (!isValidproject) { 130 | await invalidProjectWarningMessage(); 131 | } else { 132 | await interactiveMode(runOptions); 133 | } 134 | return; 135 | } 136 | 137 | await runAll(runOptions); 138 | } catch (error: any) { 139 | console.error(`\n${red('✖')} ${error.message || error}`); 140 | handleCliError(error); 141 | process.exit(1); 142 | } 143 | } 144 | ); 145 | 146 | process.on('SIGINT', () => { 147 | console.log('\n'); 148 | outro(red('Stopping.')); 149 | console.log('\n'); 150 | process.exit(); 151 | }); 152 | -------------------------------------------------------------------------------- /test/nextjs-app/app/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const navItems = [ 4 | { 5 | title: 'PRODUCT', 6 | links: [ 7 | { name: 'Features', href: '#' }, 8 | { name: 'Pricing', href: '#' }, 9 | ], 10 | }, 11 | { 12 | title: 'DEVELOPERS', 13 | links: [ 14 | { name: 'Builder for Developers', href: '#' }, 15 | { name: 'Developer Docs', href: '#' }, 16 | { name: 'Open Source Projects', href: '#' }, 17 | { name: 'Performance Insights', href: '#' }, 18 | ], 19 | }, 20 | { 21 | title: 'INTEGRATIONS', 22 | links: [ 23 | { name: 'All Integrations', href: '#' }, 24 | { name: 'Shopify', href: '#' }, 25 | { name: 'React', href: '#' }, 26 | { name: 'Angular', href: '#' }, 27 | { name: 'Next.js', href: '#' }, 28 | { name: 'Gatsby', href: '#' }, 29 | ], 30 | }, 31 | { 32 | title: 'USE CASES', 33 | links: [ 34 | { name: 'Landing Pages', href: '#' }, 35 | { name: 'Shopify Storefront', href: '#' }, 36 | { name: 'Headless CMS', href: '#' }, 37 | { name: 'Headless Storefront', href: '#' }, 38 | { name: 'Customer Showcases', href: '#' }, 39 | { name: 'Customer Success Stories', href: '#' }, 40 | ], 41 | }, 42 | { 43 | title: 'RESOURCES', 44 | links: [ 45 | { name: 'User Guides', href: '#' }, 46 | { name: 'Blog', href: '#' }, 47 | { name: 'Community Forum', href: '#' }, 48 | { name: 'Templates', href: '#' }, 49 | { name: 'Partners', href: '#' }, 50 | { name: 'Submit an Idea', href: '#' }, 51 | ], 52 | }, 53 | { 54 | title: 'COMPANY', 55 | links: [ 56 | { name: 'About', href: '#' }, 57 | { name: 'Careers', href: '#' }, 58 | ], 59 | }, 60 | ]; 61 | 62 | const Home = () => { 63 | return ( 64 |
65 | {/* Header Section */} 66 |
67 | logo 72 |

73 | Visually build and optimize digital experiences on any tech stack. No 74 | coding required, and developer approved. 75 |

76 |
77 | 78 | {/* Divider Line */} 79 |
80 | 81 | {/* Main Content Section */} 82 |
83 | {/* Left Section with Buttons */} 84 |
85 | 88 | 91 |
92 | 93 | {/* Navigation Section */} 94 |
95 | {navItems.map((section) => ( 96 |
97 |

98 | {section.title} 99 |

100 | 112 |
113 | ))} 114 |
115 |
116 | 117 | {/* Footer Section */} 118 | 145 |
146 | ); 147 | }; 148 | 149 | export default Home; 150 | -------------------------------------------------------------------------------- /src/helpers/config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import os from 'os'; 4 | import ini from 'ini'; 5 | import type { TiktokenModel } from '@dqbd/tiktoken'; 6 | import { KnownError, handleCliError } from './error'; 7 | import * as p from '@clack/prompts'; 8 | import { red } from 'kolorist'; 9 | 10 | const { hasOwnProperty } = Object.prototype; 11 | export const hasOwn = (object: unknown, key: PropertyKey) => 12 | hasOwnProperty.call(object, key); 13 | 14 | const configParsers = { 15 | OPENAI_KEY(key?: string) { 16 | return key; 17 | }, 18 | ANTHROPIC_KEY(key?: string) { 19 | return key; 20 | }, 21 | MODEL(model?: string) { 22 | if (!model || model.length === 0) { 23 | return 'gpt-4o'; 24 | } 25 | 26 | return model as TiktokenModel; 27 | }, 28 | ANTHROPIC_MODEL(model?: string) { 29 | if (!model || model.length === 0) { 30 | return 'claude-3-5-sonnet-20241022'; 31 | } 32 | 33 | return model; 34 | }, 35 | USE_ASSISTANT(useAssistant?: string) { 36 | return useAssistant !== 'false'; 37 | }, 38 | OPENAI_API_ENDPOINT(apiEndpoint?: string) { 39 | return apiEndpoint || 'https://api.openai.com/v1'; 40 | }, 41 | LANGUAGE(language?: string) { 42 | return language || 'en'; 43 | }, 44 | MOCK_LLM_RECORD_FILE(filename?: string) { 45 | return filename; 46 | }, 47 | USE_MOCK_LLM(useMockLlm?: string) { 48 | return useMockLlm === 'true'; 49 | }, 50 | } as const; 51 | 52 | type ConfigKeys = keyof typeof configParsers; 53 | 54 | type RawConfig = { 55 | [key in ConfigKeys]?: string; 56 | }; 57 | 58 | type ValidConfig = { 59 | [Key in ConfigKeys]: ReturnType<(typeof configParsers)[Key]>; 60 | }; 61 | 62 | const configPath = path.join(os.homedir(), '.micro-agent'); 63 | 64 | const fileExists = (filePath: string) => 65 | fs.lstat(filePath).then( 66 | () => true, 67 | () => false 68 | ); 69 | 70 | const readConfigFile = async (): Promise => { 71 | const configExists = await fileExists(configPath); 72 | if (!configExists) { 73 | return Object.create(null); 74 | } 75 | 76 | const configString = await fs.readFile(configPath, 'utf8'); 77 | return ini.parse(configString); 78 | }; 79 | 80 | export const getConfig = async ( 81 | cliConfig?: RawConfig 82 | ): Promise => { 83 | const config = await readConfigFile(); 84 | const parsedConfig: Record = {}; 85 | 86 | for (const key of Object.keys(configParsers) as ConfigKeys[]) { 87 | const parser = configParsers[key]; 88 | const value = cliConfig?.[key] ?? config[key]; 89 | parsedConfig[key] = parser(value); 90 | } 91 | 92 | return { ...(parsedConfig as ValidConfig), ...process.env }; 93 | }; 94 | 95 | export const setConfigs = async (keyValues: [key: string, value: string][]) => { 96 | const config = await readConfigFile(); 97 | 98 | for (const [key, value] of keyValues) { 99 | if (!hasOwn(configParsers, key)) { 100 | throw new KnownError(`Invalid config property: ${key}`); 101 | } 102 | 103 | const parsed = configParsers[key as ConfigKeys](value); 104 | config[key as ConfigKeys] = parsed as any; 105 | } 106 | 107 | await fs.writeFile(configPath, ini.stringify(config), 'utf8'); 108 | }; 109 | 110 | export const showConfigUI = async () => { 111 | try { 112 | const config = await getConfig(); 113 | const choice = (await p.select({ 114 | message: 'Set config' + ':', 115 | options: [ 116 | { 117 | label: 'OpenAI Key', 118 | value: 'OPENAI_KEY', 119 | hint: hasOwn(config, 'OPENAI_KEY') 120 | ? // Obfuscate the key 121 | 'sk-...' + (config.OPENAI_KEY?.slice(-3) || '') 122 | : '(not set)', 123 | }, 124 | { 125 | label: 'Anthropic Key', 126 | value: 'ANTHROPIC_KEY', 127 | hint: hasOwn(config, 'ANTHROPIC_KEY') 128 | ? // Obfuscate the key 129 | 'sk-ant-...' + (config.ANTHROPIC_KEY?.slice(-3) || '') 130 | : '(not set)', 131 | }, 132 | { 133 | label: 'Model', 134 | value: 'MODEL', 135 | hint: hasOwn(config, 'MODEL') ? config.MODEL : '(not set)', 136 | }, 137 | { 138 | label: 'OpenAI API Endpoint', 139 | value: 'OPENAI_API_ENDPOINT', 140 | hint: hasOwn(config, 'OPENAI_API_ENDPOINT') 141 | ? config.OPENAI_API_ENDPOINT 142 | : '(not set)', 143 | }, 144 | { 145 | label: 'Done', 146 | value: 'cancel', 147 | hint: 'Exit', 148 | }, 149 | ], 150 | })) as ConfigKeys | 'cancel' | symbol; 151 | 152 | if (p.isCancel(choice)) return; 153 | 154 | if (choice === 'OPENAI_KEY') { 155 | const key = await p.text({ 156 | message: 'Enter your OpenAI API key', 157 | validate: (value) => { 158 | if (!value.length) { 159 | return 'Please enter a key'; 160 | } 161 | }, 162 | }); 163 | if (p.isCancel(key)) return; 164 | await setConfigs([['OPENAI_KEY', key]]); 165 | } else if (choice === 'OPENAI_API_ENDPOINT') { 166 | const apiEndpoint = await p.text({ 167 | message: 'Enter your OpenAI API Endpoint', 168 | }); 169 | if (p.isCancel(apiEndpoint)) return; 170 | await setConfigs([['OPENAI_API_ENDPOINT', apiEndpoint]]); 171 | } else if (choice === 'MODEL') { 172 | const model = await p.text({ 173 | message: 'Enter the model you want to use', 174 | }); 175 | if (p.isCancel(model)) return; 176 | await setConfigs([['MODEL', model]]); 177 | } 178 | if (choice === 'cancel') return; 179 | await showConfigUI(); 180 | } catch (error: any) { 181 | console.error(`\n${red('✖')} ${error.message}`); 182 | handleCliError(error); 183 | process.exit(1); 184 | } 185 | }; 186 | -------------------------------------------------------------------------------- /src/helpers/run.ts: -------------------------------------------------------------------------------- 1 | import { intro, outro, log } from '@clack/prompts'; 2 | import { generate } from './generate'; 3 | import { isFail, test } from './test'; 4 | import { green, yellow } from 'kolorist'; 5 | import { commandName } from './constants'; 6 | import { visualGenerate } from './visual-generate'; 7 | import { fileExists } from './file-exists'; 8 | import { outputFile } from './output-file'; 9 | import { removeBackticks } from './remove-backticks'; 10 | import { getSimpleCompletion } from './llm'; 11 | 12 | type Options = { 13 | outputFile: string; 14 | promptFile: string; 15 | testCommand: string; 16 | testFile: string; 17 | lastRunError: string; 18 | priorCode?: string; 19 | threadId: string; 20 | visual: string; 21 | prompt?: string; 22 | interactive?: boolean; 23 | addedLogs?: boolean; 24 | }; 25 | 26 | export async function runOne(options: Options) { 27 | if (options.visual) { 28 | log.step('Running...'); 29 | const result = await visualGenerate(options); 30 | if (isFail(result.testResult)) { 31 | if (result.testResult.message.includes('Adding logs to the code')) { 32 | const codeWithLogs = await addLogsToCode(options); 33 | await outputFile(options.outputFile, codeWithLogs); 34 | return { 35 | code: codeWithLogs, 36 | testResult: result.testResult, 37 | }; 38 | } 39 | const code = result.code; 40 | await outputFile(options.outputFile, code); 41 | return { 42 | code, 43 | testResult: result.testResult, 44 | }; 45 | } else { 46 | return result; 47 | } 48 | } 49 | 50 | log.step('Generating code...'); 51 | 52 | // TODO: parse any imports in the prompt file and include them in the prompt as context 53 | const result = removeBackticks(await generate(options)); 54 | 55 | await outputFile(options.outputFile, result); 56 | log.step('Updated code'); 57 | 58 | log.step('Running tests...'); 59 | const testResult = await test(options); 60 | 61 | return { 62 | code: result, 63 | testResult, 64 | }; 65 | } 66 | 67 | export type RunOptions = Options & { 68 | maxRuns?: number; 69 | }; 70 | 71 | const useNewlinesInCommand = true; 72 | 73 | export function createCommandString(options: RunOptions) { 74 | const command = [`${commandName}`]; 75 | if (options.outputFile) { 76 | command.push(options.outputFile); 77 | } 78 | const argPrefix = useNewlinesInCommand ? '\\\n ' : ''; 79 | if (options.promptFile) { 80 | command.push(argPrefix + `-p ${options.promptFile}`); 81 | } 82 | if (options.testCommand) { 83 | command.push( 84 | argPrefix + `-t "${options.testCommand.replace(/"/g, '\\"')}"` 85 | ); 86 | } 87 | if (options.testFile) { 88 | command.push(argPrefix + `-f ${options.testFile}`); 89 | } 90 | if (options.maxRuns) { 91 | command.push(argPrefix + `-m ${options.maxRuns}`); 92 | } 93 | if (options.threadId) { 94 | command.push(argPrefix + `--thread ${options.threadId}`); 95 | } 96 | 97 | return command.join(' '); 98 | } 99 | 100 | export async function* run(options: RunOptions) { 101 | let passed = false; 102 | const maxRuns = options.maxRuns ?? 20; 103 | for (let i = 0; i < maxRuns; i++) { 104 | const result = await runOne(options); 105 | yield result; 106 | 107 | if (result.testResult.type === 'success') { 108 | outro(green('All tests passed!')); 109 | passed = true; 110 | break; 111 | } 112 | options.lastRunError = result.testResult.message; 113 | } 114 | if (!passed) { 115 | log.message(yellow(`Max runs of ${maxRuns} reached.`)); 116 | if (options.prompt && !(await fileExists(options.promptFile))) { 117 | await outputFile(options.promptFile, options.prompt); 118 | } 119 | log.info('You can resume with this command with:'); 120 | console.log(`\n${createCommandString(options)}\n`); 121 | outro(yellow('Stopping.')); 122 | console.log('\n'); 123 | } 124 | } 125 | 126 | export async function runAll( 127 | options: RunOptions & { 128 | skipIntro?: boolean; 129 | } 130 | ) { 131 | if (!options.skipIntro) { 132 | intro('🦾 Micro Agent'); 133 | } 134 | const results = []; 135 | let testResult; 136 | if (!options.visual) { 137 | log.step('Running tests...'); 138 | testResult = await test(options); 139 | 140 | if (testResult.type === 'success') { 141 | if (options.addedLogs) { 142 | const codeWithoutLogs = await removeLogsFromCode(options); 143 | await outputFile(options.outputFile, codeWithoutLogs); 144 | options.addedLogs = false; 145 | } 146 | outro(green('All tests passed!')); 147 | return; 148 | } 149 | } 150 | for await (const result of run({ 151 | ...options, 152 | lastRunError: options.lastRunError || testResult?.message || '', 153 | })) { 154 | results.push(result); 155 | } 156 | return results; 157 | } 158 | 159 | async function addLogsToCode(options: Options): Promise { 160 | const codeWithLogs = await getSimpleCompletion({ 161 | messages: [ 162 | { 163 | role: 'system', 164 | content: 165 | 'You are an assistant that helps improve code by adding logs for debugging.', 166 | }, 167 | { 168 | role: 'user', 169 | content: `Please add detailed logs to the following code to help debug repeated test failures:\n\n${options.priorCode}\n\nThe error you received on that code was:\n\n${options.lastRunError}`, 170 | }, 171 | ], 172 | }); 173 | 174 | return codeWithLogs; 175 | } 176 | 177 | async function removeLogsFromCode(options: Options): Promise { 178 | const codeWithoutLogs = await getSimpleCompletion({ 179 | messages: [ 180 | { 181 | role: 'system', 182 | content: 183 | 'You are an assistant that helps clean up code by removing logs.', 184 | }, 185 | { 186 | role: 'user', 187 | content: `Please remove all logs from the following code:\n\n${options.priorCode}`, 188 | }, 189 | ], 190 | }); 191 | return codeWithoutLogs; 192 | } 193 | -------------------------------------------------------------------------------- /src/helpers/llm.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getCompletion, 3 | getOpenAi, 4 | getSimpleCompletion, 5 | getFileSuggestion, 6 | } from './llm'; 7 | import { KnownError } from './error'; 8 | import { expect, describe, it, vi } from 'vitest'; 9 | import OpenAI from 'openai'; 10 | import { ChatCompletionMessageParam } from 'openai/resources'; 11 | import { RunOptions } from './run'; 12 | import { gray } from 'kolorist'; 13 | 14 | const mocks = vi.hoisted(() => { 15 | return { 16 | openAIConstructor: vi.fn(), 17 | getConfig: vi.fn(), 18 | create: vi.fn(), 19 | }; 20 | }); 21 | 22 | vi.mock('./config', () => { 23 | return { 24 | getConfig: mocks.getConfig, 25 | }; 26 | }); 27 | 28 | vi.mock('openai', () => { 29 | return { 30 | default: mocks.openAIConstructor, 31 | }; 32 | }); 33 | 34 | mocks.openAIConstructor.mockImplementation(() => { 35 | return { 36 | chat: { 37 | completions: { 38 | create: mocks.create, 39 | }, 40 | }, 41 | }; 42 | }); 43 | 44 | const defaultConfig = { 45 | OPENAI_KEY: 'my-openai-key', 46 | OPENAI_API_ENDPOINT: 'https://api.openai.com/v1', 47 | }; 48 | 49 | describe('getOpenAi', () => { 50 | it('should throw a KnownError if OPENAI_KEY is blank', async () => { 51 | mocks.getConfig 52 | .mockResolvedValueOnce({ OPENAI_KEY: '' }) 53 | .mockResolvedValueOnce({ OPENAI_KEY: '' }); 54 | 55 | await expect(getOpenAi()).rejects.toThrow(KnownError); 56 | await expect(getOpenAi()).rejects.toThrow( 57 | 'Missing OpenAI key. Use `micro-agent config` to set it.' 58 | ); 59 | }); 60 | 61 | it('should create a new OpenAI instance with the provided key and endpoint', async () => { 62 | mocks.getConfig.mockResolvedValueOnce(defaultConfig); 63 | 64 | await getOpenAi(); 65 | 66 | expect(OpenAI).toHaveBeenCalledWith({ 67 | apiKey: 'my-openai-key', 68 | baseURL: 'https://api.openai.com/v1', 69 | }); 70 | }); 71 | }); 72 | 73 | describe('getSimpleCompletion', () => { 74 | it('should call openai.chat.completions.create with the correct parameters', async () => { 75 | mocks.getConfig 76 | .mockResolvedValueOnce(defaultConfig) 77 | .mockResolvedValueOnce(defaultConfig); 78 | mocks.create.mockResolvedValueOnce([]); 79 | 80 | const messages: ChatCompletionMessageParam[] = [ 81 | { role: 'system', content: 'Hello' }, 82 | ]; 83 | await getSimpleCompletion({ messages }); 84 | 85 | expect(mocks.create).toHaveBeenCalledWith({ 86 | model: 'gpt-4o', 87 | messages, 88 | stream: true, 89 | seed: 42, 90 | temperature: 0, 91 | }); 92 | }); 93 | 94 | it('should concatenate the output from completion chunks', async () => { 95 | mocks.getConfig 96 | .mockResolvedValueOnce(defaultConfig) 97 | .mockResolvedValueOnce(defaultConfig); 98 | mocks.create.mockResolvedValueOnce([ 99 | { choices: [{ delta: { content: 'Hello' } }] }, 100 | { choices: [{ delta: { content: ' World' } }] }, 101 | ]); 102 | 103 | const messages: ChatCompletionMessageParam[] = [ 104 | { role: 'system', content: 'Hello' }, 105 | ]; 106 | const output = await getSimpleCompletion({ messages }); 107 | 108 | expect(output).toBe('Hello World'); 109 | }); 110 | 111 | it('should call options.onChunk for each chunk', async () => { 112 | mocks.getConfig 113 | .mockResolvedValueOnce(defaultConfig) 114 | .mockResolvedValueOnce(defaultConfig); 115 | mocks.create.mockResolvedValueOnce([ 116 | { choices: [{ delta: { content: 'Hello' } }] }, 117 | { choices: [{ delta: { content: ' World' } }] }, 118 | ]); 119 | 120 | const messages: ChatCompletionMessageParam[] = [ 121 | { role: 'system', content: 'Hello' }, 122 | ]; 123 | const onChunk = vi.fn(); 124 | const output = await getSimpleCompletion({ messages, onChunk }); 125 | 126 | expect(onChunk).toHaveBeenCalledTimes(2); 127 | expect(onChunk).toHaveBeenCalledWith('Hello'); 128 | expect(onChunk).toHaveBeenCalledWith(' World'); 129 | }); 130 | }); 131 | 132 | describe('getCompletion', () => { 133 | it('should call openai.chat.completions.create with the correct parameters', async () => { 134 | mocks.getConfig 135 | .mockResolvedValueOnce(defaultConfig) 136 | .mockResolvedValueOnce(defaultConfig); 137 | const openaiInstance = new OpenAI(); 138 | mocks.create.mockResolvedValueOnce([]); 139 | 140 | const messages: ChatCompletionMessageParam[] = [ 141 | { role: 'system', content: 'Hello' }, 142 | ]; 143 | const options = { 144 | messages, 145 | options: {} as RunOptions, 146 | useAssistant: false, 147 | }; 148 | await getCompletion(options); 149 | 150 | expect(openaiInstance.chat.completions.create).toHaveBeenCalledWith({ 151 | model: 'gpt-4o', 152 | messages, 153 | stream: true, 154 | }); 155 | }); 156 | 157 | it('should write to stdout and stderr', async () => { 158 | mocks.getConfig 159 | .mockResolvedValueOnce(defaultConfig) 160 | .mockResolvedValueOnce(defaultConfig); 161 | const openaiInstance = new OpenAI(); 162 | mocks.create.mockResolvedValueOnce([ 163 | { choices: [{ delta: { content: 'Hello' } }] }, 164 | { choices: [{ delta: { content: 'World' } }] }, 165 | ]); 166 | const stdOutWriteMock = vi.spyOn(process.stdout, 'write'); 167 | const stdErrWriteMock = vi.spyOn(process.stderr, 'write'); 168 | 169 | const messages: ChatCompletionMessageParam[] = [ 170 | { role: 'system', content: 'Hello' }, 171 | ]; 172 | const options = { 173 | messages, 174 | options: {} as RunOptions, 175 | useAssistant: false, 176 | }; 177 | await getCompletion(options); 178 | 179 | expect(stdOutWriteMock).toHaveBeenNthCalledWith(1, gray('\n│ ')); 180 | expect(stdOutWriteMock).toHaveBeenNthCalledWith(2, '\n'); 181 | expect(stdErrWriteMock).toHaveBeenCalledWith(gray('Hello')); 182 | expect(stdErrWriteMock).toHaveBeenCalledWith(gray('World')); 183 | }); 184 | }); 185 | 186 | describe('getFileSuggestion', () => { 187 | it('should return a valid file suggestion based on input', async () => { 188 | mocks.getConfig.mockResolvedValue({ 189 | ...defaultConfig, 190 | MODEL: 'gpt-4o', 191 | }); 192 | mocks.create.mockResolvedValueOnce({ 193 | choices: [ 194 | { 195 | message: { 196 | tool_calls: [ 197 | { function: { arguments: '{ "filePath": "/src/add.test.ts"}' } }, 198 | ], 199 | }, 200 | }, 201 | ], 202 | }); 203 | const prompt = 'a function that adds numbers'; 204 | const fileString = 'src/add.ts\nsrc/subtract.ts\nsrc/multiply.ts'; 205 | const expectedResult = 'src/add.test.ts'; 206 | const result = await getFileSuggestion(prompt, fileString); 207 | expect(result).to.equal(expectedResult); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /src/helpers/interactive-mode.ts: -------------------------------------------------------------------------------- 1 | import { intro, log, spinner, text } from '@clack/prompts'; 2 | 3 | import { glob } from 'glob'; 4 | import { RunOptions, runAll } from './run'; 5 | import { getFileSuggestion, getSimpleCompletion } from './llm'; 6 | import { getConfig, setConfigs } from './config'; 7 | import { readFile } from 'fs/promises'; 8 | import dedent from 'dedent'; 9 | import { formatMessage } from './test'; 10 | import { gray, green } from 'kolorist'; 11 | import { exitOnCancel } from './exit-on-cancel'; 12 | import { iterateOnTest } from './iterate-on-test'; 13 | import { outputFile } from './output-file'; 14 | import { iterateOnTestCommand } from './iterate-on-test-command'; 15 | import { getTestCommand } from './get-test-command'; 16 | import { generateAsciiTree } from './generate-ascii-tree'; 17 | 18 | export async function interactiveMode(options: Partial) { 19 | console.log(''); 20 | intro('🦾 Micro Agent'); 21 | 22 | const config = await getConfig(); 23 | 24 | if (!config.OPENAI_KEY) { 25 | const openaiKey = exitOnCancel( 26 | await text({ 27 | message: `Welcome newcomer! What is your OpenAI key? ${gray( 28 | '(this is kept private)' 29 | )}`, 30 | }) 31 | ); 32 | 33 | await setConfigs([['OPENAI_KEY', openaiKey as string]]); 34 | } 35 | 36 | const prompt = exitOnCancel( 37 | await text({ 38 | message: 'What would you like to do?', 39 | placeholder: 'A function that ...', 40 | validate: (input) => { 41 | if (input.trim().length < 10) { 42 | return 'Please provide a complete prompt'; 43 | } 44 | }, 45 | }) 46 | ); 47 | 48 | let filePath = options.outputFile; 49 | if (!filePath) { 50 | const files = await glob('*/*/*', { ignore: ['node_modules/**'] }); 51 | const fileString = generateAsciiTree(files.slice(0, 200)); 52 | const loading = spinner(); 53 | loading.start(); 54 | 55 | const recommendedFilePath = await getFileSuggestion(prompt, fileString); 56 | loading.stop(); 57 | 58 | filePath = exitOnCancel( 59 | await text({ 60 | message: 'What file would you like to create or edit?', 61 | defaultValue: recommendedFilePath!, 62 | placeholder: recommendedFilePath!, 63 | }) 64 | ); 65 | } 66 | 67 | const testFiles = await glob(filePath.replace(/.(\w+)$/, '.{test,spec}.$1')); 68 | let testFilePath = testFiles[0]; 69 | if (!testFilePath) { 70 | log.info('Generating test...'); 71 | process.stdout.write(formatMessage('\n')); 72 | 73 | const exampleTests = await glob('**/*.{test,spec}.*', { 74 | ignore: ['node_modules/**'], 75 | }); 76 | const twoTests = exampleTests.slice(0, 2); 77 | const twoTestFiles = await Promise.all( 78 | twoTests.map(async (test) => { 79 | const content = await readFile(test, 'utf8'); 80 | return content; 81 | }) 82 | ); 83 | 84 | const packageJsonContents = await readFile('package.json', 'utf8').catch( 85 | () => '' 86 | ); 87 | 88 | testFilePath = filePath.replace(/.(\w+)$/, '.test.$1'); 89 | 90 | let testContents = getCodeBlock( 91 | (await getSimpleCompletion({ 92 | onChunk: (chunk) => { 93 | process.stderr.write(formatMessage(chunk)); 94 | }, 95 | messages: [ 96 | { 97 | role: 'system', 98 | content: dedent` 99 | You are an AI assistant that given a user prompt, returns a markdown for a unit test. 100 | 1. Think step by step before emiting any code. Think about the shape of the input and output, the behavior and special situations that are relevant to the algorithm. 101 | 102 | 2. After planning, return a code block with the test code. 103 | - Start with the most basic test case and progress to more complex ones. 104 | - Start with the happy path, then edge cases. 105 | - Inputs that are invalid, and likely to break the algorithm. 106 | - Keep the individual tests small and focused. 107 | - Focus in behavior, not implementation. 108 | 109 | Stop emitting after the code block.`, 110 | }, 111 | { 112 | role: 'user', 113 | content: dedent` 114 | Please prepare a unit test file (can be multiple tests) for the following prompt: 115 | 116 | ${prompt} 117 | 118 | 119 | The test will be located at \`${testFilePath}\` and the code to test will be located at 120 | \`${filePath}\`. 121 | 122 | ${ 123 | twoTests.length > 0 124 | ? dedent`Here is a copy of a couple example tests in the repo: 125 | 126 | ${twoTestFiles.join('\n') || 'No tests found'} 127 | ` 128 | : packageJsonContents 129 | ? dedent` 130 | Here is the package.json file to help you know what testing library to use (if any, otherwise vitest is a good option): 131 | 132 | ${packageJsonContents} 133 | 134 | ` 135 | : '' 136 | } 137 | 138 | Only output the test code. No other words, just the code. 139 | `, 140 | }, 141 | ], 142 | }))! 143 | ); 144 | 145 | const result = exitOnCancel( 146 | await text({ 147 | message: 148 | 'How does the generated test look? Reply "good", or provide feedback', 149 | defaultValue: 'good', 150 | placeholder: 'good', 151 | }) 152 | ); 153 | 154 | if (result.toLowerCase().trim() !== 'good') { 155 | options.testFile = testFilePath; 156 | options.outputFile = filePath; 157 | options.prompt = prompt; 158 | testContents = await iterateOnTest({ 159 | testCode: testContents, 160 | feedback: result, 161 | options, 162 | }); 163 | } 164 | 165 | // TODO: generate dir if one doesn't exist yet 166 | await outputFile(testFilePath, testContents); 167 | log.success(`${green('Test file generated!')} ${gray(`${testFilePath}`)}`); 168 | } 169 | options.testFile = testFilePath; 170 | 171 | const loading = spinner(); 172 | loading.start(); 173 | const defaultTestCommand = await getTestCommand({ testFilePath }); 174 | loading.stop(); 175 | let testCommand = exitOnCancel( 176 | await text({ 177 | message: 'What command should I run to test the code?', 178 | defaultValue: defaultTestCommand, 179 | placeholder: defaultTestCommand, 180 | }) 181 | ); 182 | 183 | testCommand = await iterateOnTestCommand({ testCommand }); 184 | 185 | log.info(`Agent running...`); 186 | 187 | await runAll({ 188 | skipIntro: true, 189 | threadId: options.threadId || '', 190 | maxRuns: options.maxRuns || 20, 191 | visual: options.visual || '', 192 | ...options, 193 | testCommand: testCommand, 194 | outputFile: filePath, 195 | testFile: testFilePath, 196 | promptFile: filePath.replace(/.(\w+)$/, '.prompt.md'), 197 | prompt, 198 | lastRunError: '', 199 | interactive: true, 200 | }); 201 | } 202 | 203 | export function getCodeBlock(output: string) { 204 | const foundCode = output.indexOf('```'); 205 | if (foundCode === -1) { 206 | return output; 207 | } 208 | const start = output.indexOf('\n', foundCode); 209 | if (start === -1) { 210 | return output.slice(foundCode); 211 | } 212 | const end = output.indexOf('```', start); 213 | if (end === -1) { 214 | console.error('Code block end not found'); 215 | } 216 | return output.slice(start, end === -1 ? undefined : end).trim(); 217 | } 218 | -------------------------------------------------------------------------------- /src/helpers/visual-generate.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { findVisualFile } from './find-visual-file'; 3 | import { getCompletion } from './llm'; 4 | import { RunOptions } from './run'; 5 | import { readFile } from 'fs/promises'; 6 | import { success, fail, formatMessage } from './test'; 7 | import { getScreenshot } from './get-screenshot'; 8 | import { KnownError } from './error'; 9 | import { applyUnifiedDiff } from './apply-unified-diff'; 10 | import { removeBackticks } from './remove-backticks'; 11 | import { bufferToBase64Url, imageFilePathToBase64Url } from './base64'; 12 | import Anthropic from '@anthropic-ai/sdk'; 13 | import { getConfig } from './config'; 14 | import { visualTest } from './visual-test'; 15 | import { outputFile } from './output-file'; 16 | 17 | const USE_ANTHROPIC = false; 18 | const USE_VISUAL_TEST = true as boolean; 19 | 20 | export const systemPrompt = 21 | "You take a prompt and generate code accordingly. Use placeholders (e.g. https://placehold.co/600x400) for any new images that weren't in the code previously. Don't make up image paths, always use placeholers from placehold.co"; 22 | 23 | export async function visualGenerate(options: RunOptions) { 24 | const filename = await findVisualFile(options); 25 | if (!filename) { 26 | throw new KnownError( 27 | dedent`No image file found. Please specify a file, or put one next to the file you are editing like: 28 | 29 | ./editing-file.ts 30 | ./editing-file.png # <- image file we'll use 31 | ` 32 | ); 33 | } 34 | 35 | const prompt = 36 | options.prompt || 37 | (await readFile(options.promptFile, 'utf-8').catch(() => '')); 38 | const priorCode = await readFile(options.outputFile, 'utf-8').catch(() => ''); 39 | 40 | let visualTestResult = USE_VISUAL_TEST && (await visualTest(options)); 41 | if ( 42 | visualTestResult && 43 | visualTestResult 44 | .toLowerCase() 45 | .trim() 46 | .replace(/"/g, '') 47 | .startsWith('looks good') 48 | ) { 49 | return { code: priorCode, testResult: success() }; 50 | } 51 | 52 | const asDiff = false; 53 | const asJsonDiff = false; 54 | 55 | visualTestResult = 56 | 'The "get started" and "login" buttons should be to the left of the columns with links, not above them'; 57 | 58 | const userPrompt = dedent` 59 | Here is a design I am trying to make my code match (attached image). Currently, its not quite right. 60 | 61 | Ignore placeholder images (gray boxes), those are intentional when present and will be fixed later. 62 | 63 | Heres some exampels of things that are wrong between the code and image that need fixing. 64 | Fix any other discrepancies you see too. I want the code to match the design as closely as possible. 65 | 66 | ${ 67 | visualTestResult || 68 | 'Make the code match the original design as close as possible.' 69 | } 70 | 71 | 72 | The current code is: 73 | 74 | ${priorCode || 'None'} 75 | 76 | 77 | If the updates to the code are substrantial, its ok to completely rewrite the code from scratch. 78 | 79 | Here are additional instructions from the user: 80 | 81 | ${prompt || 'None provided'} 82 | 83 | 84 | The file path for the above is ${options.outputFile}. 85 | 86 | ${ 87 | !asDiff 88 | ? '' 89 | : dedent` 90 | Give me the code as a unified diff from the current code, not the entire file. 91 | ` 92 | } 93 | 94 | ${ 95 | !asJsonDiff 96 | ? '' 97 | : dedent` 98 | Give me the code as a JSON diff from the current code, not the entire file. I will split split the current file 99 | into lines (an array of strings where each sttring is one line) and you will need to provide the json patches to make 100 | the current code match the design. Then I will apply the patches you give me to the array and then combine 101 | the array of strings back into a single string to get the new code. 102 | ` 103 | } 104 | `; 105 | 106 | const designUrl = await imageFilePathToBase64Url(filename); 107 | const debugImageOutputFolder = 'debug/images'; 108 | 109 | await outputFile(`${debugImageOutputFolder}/design-image-url.txt`, designUrl); 110 | 111 | const screenshotUrl = bufferToBase64Url(await getScreenshot(options)); 112 | await outputFile( 113 | `${debugImageOutputFolder}/screenshot-image-url.txt`, 114 | screenshotUrl 115 | ); 116 | 117 | let output: string; 118 | if (USE_ANTHROPIC) { 119 | const { ANTHROPIC_KEY, ANTHROPIC_MODEL } = await getConfig(); 120 | const anthropic = new Anthropic({ 121 | apiKey: ANTHROPIC_KEY, 122 | }); 123 | output = await new Promise((resolve, reject) => { 124 | let responseText = ''; 125 | 126 | process.stdout.write(formatMessage('\n')); 127 | anthropic.messages 128 | .stream({ 129 | model: ANTHROPIC_MODEL || 'claude-3-5-sonnet-20241022', 130 | max_tokens: 4096, 131 | system: systemPrompt, 132 | messages: [ 133 | { 134 | role: 'user', 135 | content: [ 136 | { 137 | type: 'image', 138 | source: { 139 | type: 'base64', 140 | media_type: 'image/png', 141 | data: designUrl.split(',')[1], 142 | }, 143 | }, 144 | { type: 'text', text: userPrompt }, 145 | ], 146 | }, 147 | ], 148 | }) 149 | .on('text', (text) => { 150 | responseText += text; 151 | process.stderr.write(formatMessage(text)); 152 | }) 153 | .on('end', () => { 154 | process.stdout.write('\n'); 155 | resolve(responseText); 156 | }) 157 | .on('error', (error) => { 158 | reject(error); 159 | }); 160 | }); 161 | } else { 162 | output = await getCompletion({ 163 | useAssistant: false, 164 | messages: [ 165 | { 166 | role: 'system', 167 | content: systemPrompt, 168 | }, 169 | { 170 | role: 'user', 171 | content: [ 172 | { 173 | type: 'image_url', 174 | image_url: { 175 | url: designUrl, 176 | detail: 'high', 177 | }, 178 | }, 179 | { 180 | type: 'image_url', 181 | image_url: { 182 | url: screenshotUrl, 183 | detail: 'high', 184 | }, 185 | }, 186 | { type: 'text', text: userPrompt }, 187 | ], 188 | }, 189 | ], 190 | options, 191 | }); 192 | } 193 | 194 | if (output?.toLowerCase().trim().startsWith('looks good') || !output) { 195 | return { code: priorCode, testResult: success() }; 196 | } else { 197 | const stripped = removeBackticks(output); 198 | 199 | if (asJsonDiff) { 200 | const parsed = JSON.parse(stripped); 201 | const priorLines = priorCode.split('\n'); 202 | const newLines = parsed.reduce((acc: string[], patch: any) => { 203 | if (patch.op === 'add') { 204 | acc.push(patch.value); 205 | } else if (patch.op === 'remove') { 206 | acc.pop(); 207 | } else if (patch.op === 'replace') { 208 | acc.pop(); 209 | acc.push(patch.value); 210 | } 211 | return acc; 212 | }, priorLines); 213 | const newCode = newLines.join('\n'); 214 | return { 215 | code: newCode, 216 | testResult: fail('Code does not yet match design'), 217 | }; 218 | } else if (asDiff) { 219 | const newCode = applyUnifiedDiff(stripped, priorCode); 220 | return { 221 | code: newCode, 222 | testResult: fail('Code does not yet match design'), 223 | }; 224 | } else { 225 | return { 226 | code: stripped, 227 | testResult: fail('Code does not yet match design'), 228 | }; 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/helpers/config.test.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import path from 'path'; 3 | import { expect, describe, it, vi, afterEach } from 'vitest'; 4 | import { getConfig, setConfigs, showConfigUI } from './config'; 5 | 6 | const mocks = vi.hoisted(() => { 7 | return { 8 | lstat: vi.fn(), 9 | readFile: vi.fn(), 10 | writeFile: vi.fn(), 11 | select: vi.fn(), 12 | text: vi.fn(), 13 | isCancel: vi.fn(), 14 | exit: vi.fn(), 15 | }; 16 | }); 17 | 18 | vi.mock('fs/promises', () => { 19 | return { 20 | default: { 21 | lstat: mocks.lstat, 22 | readFile: mocks.readFile, 23 | writeFile: mocks.writeFile, 24 | }, 25 | }; 26 | }); 27 | 28 | vi.mock('@clack/prompts', () => { 29 | return { 30 | select: mocks.select, 31 | text: mocks.text, 32 | isCancel: mocks.isCancel, 33 | }; 34 | }); 35 | 36 | const realProcess = process; 37 | global.process = { ...realProcess, exit: mocks.exit }; 38 | 39 | const configFilePath = path.join(os.homedir(), '.micro-agent'); 40 | const newline = os.platform() === 'win32' ? '\r\n' : '\n'; 41 | 42 | describe('getConfig', () => { 43 | const defaultConfig = { 44 | ANTHROPIC_KEY: undefined, 45 | USE_ASSISTANT: true, 46 | ANTHROPIC_MODEL: 'claude-3-5-sonnet-20241022', 47 | LANGUAGE: 'en', 48 | MODEL: 'gpt-4o', 49 | OPENAI_API_ENDPOINT: 'https://api.openai.com/v1', 50 | OPENAI_KEY: undefined, 51 | USE_MOCK_LLM: false, 52 | MOCK_LLM_RECORD_FILE: undefined, 53 | }; 54 | 55 | it('should return an object with defaults and the env if no config is provided', async () => { 56 | mocks.lstat.mockRejectedValueOnce( 57 | new Error('ENOENT: no such file or directory') 58 | ); 59 | const result = await getConfig(); 60 | expect(result).toEqual({ 61 | ...defaultConfig, 62 | ...process.env, 63 | }); 64 | }); 65 | 66 | it('should return the parsed config object if a valid config is provided', async () => { 67 | mocks.lstat.mockRejectedValueOnce( 68 | new Error('ENOENT: no such file or directory') 69 | ); 70 | const cliConfig = { 71 | OPENAI_KEY: 'my-openai-key', 72 | MODEL: 'gpt-3.5-turbo', 73 | LANGUAGE: 'en', 74 | }; 75 | const result = await getConfig(cliConfig); 76 | expect(result).toEqual({ 77 | ...defaultConfig, 78 | ...cliConfig, 79 | ...process.env, 80 | }); 81 | }); 82 | 83 | it('should ignore invalid config keys', async () => { 84 | mocks.lstat.mockRejectedValueOnce( 85 | new Error('ENOENT: no such file or directory') 86 | ); 87 | const cliConfig = { 88 | OPENAI_KEY: 'my-openai-key', 89 | INVALID_KEY: 'invalid-value', 90 | }; 91 | const result = await getConfig(cliConfig); 92 | expect(result).toEqual({ 93 | ...defaultConfig, 94 | OPENAI_KEY: 'my-openai-key', 95 | ...process.env, 96 | }); 97 | }); 98 | 99 | it('should check if the config file exists', async () => { 100 | mocks.lstat.mockResolvedValueOnce(true); 101 | mocks.readFile.mockResolvedValueOnce(''); 102 | 103 | await getConfig(); 104 | 105 | expect(mocks.lstat).toHaveBeenCalledWith(configFilePath); 106 | }); 107 | 108 | it('should read the config file if it exists', async () => { 109 | mocks.lstat.mockResolvedValueOnce(true); 110 | mocks.readFile.mockResolvedValueOnce(''); 111 | 112 | await getConfig(); 113 | 114 | expect(mocks.readFile).toHaveBeenCalledWith(configFilePath, 'utf8'); 115 | }); 116 | 117 | it('should return the parsed config object from the config file', async () => { 118 | const expected = { 119 | OPENAI_KEY: 'my-openai-key', 120 | MODEL: 'gpt-3.5-turbo', 121 | LANGUAGE: 'en', 122 | }; 123 | mocks.lstat.mockResolvedValueOnce(true); 124 | mocks.readFile.mockResolvedValueOnce( 125 | `OPENAI_KEY=my-openai-key${newline}MODEL=gpt-3.5-turbo${newline}LANGUAGE=en${newline}` 126 | ); 127 | 128 | const result = await getConfig(); 129 | 130 | expect(result).toEqual({ 131 | ...defaultConfig, 132 | ...expected, 133 | ...process.env, 134 | }); 135 | }); 136 | 137 | it('should ignore invalid config keys in the config file', async () => { 138 | mocks.lstat.mockResolvedValueOnce(true); 139 | mocks.readFile.mockResolvedValueOnce( 140 | 'OPENAI_KEY=my-openai-key\nINVALID_KEY=invalid-value\n' 141 | ); 142 | const result = await getConfig(); 143 | expect(result).toEqual({ 144 | ...defaultConfig, 145 | OPENAI_KEY: 'my-openai-key', 146 | ...process.env, 147 | }); 148 | }); 149 | }); 150 | 151 | describe('setConfigs', () => { 152 | it('should write the provided key-value pairs to the config file', async () => { 153 | mocks.lstat.mockResolvedValueOnce(true); 154 | mocks.readFile.mockResolvedValueOnce(''); 155 | const keyValues: [string, string][] = [ 156 | ['OPENAI_KEY', 'my-openai-key'], 157 | ['MODEL', 'gpt-3.5-turbo'], 158 | ['LANGUAGE', 'en'], 159 | ]; 160 | 161 | await setConfigs(keyValues); 162 | 163 | expect(mocks.writeFile).toHaveBeenCalledWith( 164 | configFilePath, 165 | `OPENAI_KEY=my-openai-key${newline}MODEL=gpt-3.5-turbo${newline}LANGUAGE=en${newline}`, 166 | 'utf8' 167 | ); 168 | }); 169 | 170 | it('should throw an error for invalid config keys', async () => { 171 | mocks.lstat.mockResolvedValueOnce(true); 172 | mocks.readFile.mockResolvedValueOnce(''); 173 | const keyValues: [string, string][] = [ 174 | ['OPENAI_KEY', 'my-openai-key'], 175 | ['INVALID_KEY', 'invalid-value'], 176 | ]; 177 | 178 | await expect(setConfigs(keyValues)).rejects.toThrow( 179 | 'Invalid config property: INVALID_KEY' 180 | ); 181 | }); 182 | }); 183 | 184 | describe('showConfigUI', () => { 185 | it('should show the basic config options', async () => { 186 | mocks.lstat.mockRejectedValue( 187 | new Error('ENOENT: no such file or directory') 188 | ); 189 | mocks.select.mockResolvedValueOnce('cancel'); 190 | 191 | await showConfigUI(); 192 | 193 | expect(mocks.select).toHaveBeenCalledWith({ 194 | message: 'Set config' + ':', 195 | options: [ 196 | { 197 | label: 'OpenAI Key', 198 | value: 'OPENAI_KEY', 199 | hint: 'sk-...', 200 | }, 201 | { 202 | label: 'Anthropic Key', 203 | value: 'ANTHROPIC_KEY', 204 | hint: 'sk-ant-...', 205 | }, 206 | { 207 | label: 'Model', 208 | value: 'MODEL', 209 | hint: 'gpt-4o', 210 | }, 211 | { 212 | label: 'OpenAI API Endpoint', 213 | value: 'OPENAI_API_ENDPOINT', 214 | hint: 'https://api.openai.com/v1', 215 | }, 216 | { 217 | label: 'Done', 218 | value: 'cancel', 219 | hint: 'Exit', 220 | }, 221 | ], 222 | }); 223 | }); 224 | 225 | it('should return nothing if the user cancels', async () => { 226 | mocks.lstat.mockRejectedValue( 227 | new Error('ENOENT: no such file or directory') 228 | ); 229 | mocks.select.mockResolvedValueOnce('cancel'); 230 | 231 | expect(await showConfigUI()).toBeUndefined(); 232 | }); 233 | 234 | it('should ask the user to set the OpenAI key', async () => { 235 | mocks.lstat.mockRejectedValue( 236 | new Error('ENOENT: no such file or directory') 237 | ); 238 | mocks.select 239 | .mockResolvedValueOnce('OPENAI_KEY') 240 | .mockResolvedValueOnce('cancel'); 241 | mocks.text.mockResolvedValueOnce('my-openai-key'); 242 | 243 | await showConfigUI(); 244 | 245 | expect(mocks.text).toHaveBeenCalledTimes(1); 246 | expect(mocks.text).toHaveBeenCalledWith({ 247 | message: 'Enter your OpenAI API key', 248 | validate: expect.any(Function), 249 | }); 250 | }); 251 | 252 | it('should set the OpenAI key if the user provides one', async () => { 253 | mocks.lstat.mockRejectedValue( 254 | new Error('ENOENT: no such file or directory') 255 | ); 256 | mocks.select 257 | .mockResolvedValueOnce('OPENAI_KEY') 258 | .mockResolvedValueOnce('cancel'); 259 | mocks.text.mockResolvedValueOnce('my-openai-key'); 260 | 261 | await showConfigUI(); 262 | 263 | expect(mocks.writeFile).toHaveBeenCalledTimes(1); 264 | expect(mocks.writeFile).toHaveBeenCalledWith( 265 | configFilePath, 266 | `OPENAI_KEY=my-openai-key${newline}`, 267 | 'utf8' 268 | ); 269 | }); 270 | 271 | it('should ask the user to set the OpenAI API endpoint', async () => { 272 | mocks.lstat.mockRejectedValue( 273 | new Error('ENOENT: no such file or directory') 274 | ); 275 | mocks.select 276 | .mockResolvedValueOnce('OPENAI_API_ENDPOINT') 277 | .mockResolvedValueOnce('cancel'); 278 | mocks.text.mockResolvedValueOnce('https://api.openai.com/v1'); 279 | 280 | await showConfigUI(); 281 | 282 | expect(mocks.text).toHaveBeenCalledTimes(1); 283 | expect(mocks.text).toHaveBeenCalledWith({ 284 | message: 'Enter your OpenAI API Endpoint', 285 | }); 286 | }); 287 | 288 | it('should set the OpenAI API endpoint if the user provides one', async () => { 289 | mocks.lstat.mockRejectedValue( 290 | new Error('ENOENT: no such file or directory') 291 | ); 292 | mocks.select 293 | .mockResolvedValueOnce('OPENAI_API_ENDPOINT') 294 | .mockResolvedValueOnce('cancel'); 295 | mocks.text.mockResolvedValueOnce('https://api.openai.com/v1'); 296 | 297 | await showConfigUI(); 298 | 299 | expect(mocks.writeFile).toHaveBeenCalledTimes(1); 300 | expect(mocks.writeFile).toHaveBeenCalledWith( 301 | configFilePath, 302 | `OPENAI_API_ENDPOINT=https://api.openai.com/v1${newline}`, 303 | 'utf8' 304 | ); 305 | }); 306 | 307 | it('should ask the user to set the model', async () => { 308 | mocks.lstat.mockRejectedValue( 309 | new Error('ENOENT: no such file or directory') 310 | ); 311 | mocks.select.mockResolvedValueOnce('MODEL').mockResolvedValueOnce('cancel'); 312 | mocks.text.mockResolvedValueOnce('gpt-4o'); 313 | 314 | await showConfigUI(); 315 | 316 | expect(mocks.text).toHaveBeenCalledTimes(1); 317 | expect(mocks.text).toHaveBeenCalledWith({ 318 | message: 'Enter the model you want to use', 319 | }); 320 | }); 321 | 322 | it('should set the model if the user provides one', async () => { 323 | mocks.lstat.mockRejectedValue( 324 | new Error('ENOENT: no such file or directory') 325 | ); 326 | mocks.select.mockResolvedValueOnce('MODEL').mockResolvedValueOnce('cancel'); 327 | mocks.text.mockResolvedValueOnce('gpt-4o'); 328 | 329 | await showConfigUI(); 330 | 331 | expect(mocks.writeFile).toHaveBeenCalledTimes(1); 332 | expect(mocks.writeFile).toHaveBeenCalledWith( 333 | configFilePath, 334 | `MODEL=gpt-4o${newline}`, 335 | 'utf8' 336 | ); 337 | }); 338 | 339 | afterEach(() => { 340 | vi.restoreAllMocks(); 341 | }); 342 | }); 343 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | AI Shell logo 6 | 7 |
8 | 9 |

10 | An AI agent that writes and fixes code for you. 11 |

12 | 13 |

14 | Current version 15 |

16 |
17 | 18 | ![Demo](https://cdn.builder.io/api/v1/file/assets%2FYJIGb4i01jvw0SRdL5Bt%2F3306a1cff57b4be69df65492a72ae8e5) 19 | 20 | # Micro Agent 21 | 22 | Just run `micro-agent`, give it a prompt, and it'll generate a test and then iterate on code until all test cases pass. 23 | 24 | ## Why? 25 | 26 | LLMs are great at giving you broken code, and it can take repeat iteration to get that code to work as expected. 27 | 28 | So why do this manually when AI can handle not just the generation but also the iteration and fixing? 29 | 30 | ### Why a "micro" agent? 31 | 32 | AI agents are cool, but general-purpose coding agents rarely work as hoped or promised. They tend to go haywire with compounding errors. Think of your Roomba getting stuck under a table, x1000. 33 | 34 | The idea of a micro agent is to 35 | 36 | 1. Create a definitive test case that can give clear feedback if the code works as intended or not, and 37 | 2. Iterate on code until all test cases pass 38 | 39 | Read more on [why Micro Agent exists](https://www.builder.io/blog/micro-agent). 40 | 41 | Micro Agent Diagram 42 | 43 | ### What this project is not 44 | 45 | This project is not trying to be an end-to-end developer. AI agents are not capable enough to reliably try to be that yet (or probably very soon). This project won't install modules, read and write multiple files, or do anything else that is highly likely to cause havoc when it inevitably fails. 46 | 47 | It's a micro agent. It's small, focused, and does one thing as well as possible: write a test, then produce code that passes that test. 48 | 49 | ## Installation 50 | 51 | > Micro Agent requires [Node.js](https://nodejs.org/) v18 or later. 52 | 53 | ```bash 54 | npm install -g @builder.io/micro-agent 55 | ``` 56 | 57 | ## Getting Started 58 | 59 | The best way to get started is to run Micro Agent in interactive mode, where it will ask you questions about the code it generates and use your feedback to improve the code it generates. 60 | 61 | ```bash 62 | micro-agent 63 | ``` 64 | 65 | Look at that, you're now a test-driven developer. You're welcome. 66 | 67 | ## Running Manually 68 | 69 | ### Add an LLM API key 70 | 71 | Micro Agent works with Claude, OpenAI, Ollama, or any OpenAI compatible provider such as Groq. You need to add your API key to the CLI: 72 | 73 | ```bash 74 | micro-agent config set OPENAI_KEY= 75 | micro-agent config set MODEL=gpt-4o 76 | ``` 77 | 78 | Or, for Claude: 79 | 80 | ```bash 81 | micro-agent config set ANTHROPIC_KEY= 82 | micro-agent config set MODEL=claude 83 | ``` 84 | 85 | To use a custom OpenAI API endpoint, such as for use with Ollama or Groq, you can set the endpoint with: 86 | 87 | ```bash 88 | micro-agent config set OPENAI_API_ENDPOINT= 89 | micro-agent config set OPENAI_API_ENDPOINT=https://api.groq.com/openai/v1 90 | ``` 91 | 92 | ### Unit test matching 93 | 94 | ![Demo](https://cdn.builder.io/api/v1/file/assets%2FYJIGb4i01jvw0SRdL5Bt%2F4e8b02abb3e044118f070d9a7253003e) 95 | 96 | To run the Micro Agent on a file in unit test matching mode, you need to provide a test script that will run after each code generation attempt. For instance: 97 | 98 | ```bash 99 | micro-agent ./file-to-edit.ts -t "npm test" 100 | ``` 101 | 102 | This will run the Micro Agent on the file `./file-to-edit.ts` running `npm test` and will write code until the tests pass. 103 | 104 | The above assumes the following file structure: 105 | 106 | ```bash 107 | some-folder 108 | ├──file-to-edit.ts 109 | ├──file-to-edit.test.ts # test file. if you need a different path, use the -f argument 110 | └──file-to-edit.prompt.md # optional prompt file. if you need a different path, use the -p argument 111 | ``` 112 | 113 | By default, Micro Agent assumes you have a test file with the same name as the editing file but with `.test.ts` appended, such as `./file-to-edit.test.ts` for the above examples. 114 | 115 | If this is not the case, you can specify the test file with the `-f` flag. You can also add a prompt to help guide the code generation, either at a file located at `.prompt.md` like `./file-to-edit.prompt.md` or by specifying the prompt file with the `-p`. For instance: 116 | 117 | ```bash 118 | micro-agent ./file-to-edit.ts -t "npm test" -f ./file-to-edit.spec.ts -p ./path-to-prompt.prompt.md 119 | ``` 120 | 121 | ### Visual matching (experimental) 122 | 123 | ![Visual Demo](https://cdn.builder.io/api/v1/file/assets%2FYJIGb4i01jvw0SRdL5Bt%2Fe90f6d4158b44a8fb9adeee3be3dbe82) 124 | 125 | > [!WARNING] 126 | > This feature is experimental and under active development. Use with caution. 127 | 128 | Micro Agent can also help you match a design. To do this, you need to provide a design and a local URL to your rendered code. For instance: 129 | 130 | ```bash 131 | micro-agent ./app/about/page.tsx --visual localhost:3000/about 132 | ``` 133 | 134 | Micro agent will then generate code until the rendered output of your code more closely matches a screenshot file that you place next to the code you are editing (in this case, it would be `./app/about/page.png`). 135 | 136 | The above assumes the following file structure: 137 | 138 | ```bash 139 | app/about 140 | ├──page.tsx # The code to edit 141 | ├──page.png # The screenshot to match 142 | └──page.prompt.md # Optional, additional instructions for the AI 143 | ``` 144 | 145 | ### Adding an Anthropic API key 146 | 147 | > [!NOTE] 148 | > Using the visual matching feature requires an Anthropic API key. 149 | 150 | OpenAI is simply just not good at visual matching. We recommend using [Anthropic](https://anthropic.com/) for visual matching. To use Anthropic, you need to add your API key to the CLI: 151 | 152 | ```bash 153 | micro-agent config set ANTHROPIC_KEY= 154 | ``` 155 | 156 | Visual matching uses a multi-agent approach where Anthropic Claude Opus will do the visual matching and feedback, and then OpenAI will generate the code to match the design and address the feedback. 157 | 158 | ![Visual of the multi agent approach](https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F427929ba84b34ac6a0f1fda104e60ecd) 159 | 160 | ### Integration with Figma 161 | 162 | Micro Agent can also integrate with [Visual Copilot](https://www.builder.io/c/docs/visual-copilot) to connect directly with Figma to ensure the highest fidelity possible design to code, including fully reusing the exact components and design tokens from your codebase. 163 | 164 | Visual Copilot connects directly to Figma to assist with pixel perfect conversion, exact design token mapping, and precise reusage of your components in the generated output. 165 | 166 | Then, Micro Agent can take the output of Visual Copilot and make final adjustments to the code to ensure it passes TSC, lint, tests, and fully matches your design including final tweaks. 167 | 168 | ![Visual Copilot demo](https://cdn.builder.io/api/v1/file/assets%2FYJIGb4i01jvw0SRdL5Bt%2Fa503ad8367d746f3879db1a155728cb2) 169 | 170 | ## Configuration 171 | 172 | ### Max runs 173 | 174 | By default, Micro Agent will do 10 runs. If tests don't pass in 10 runs, it will stop. You can change this with the `-m` flag, like `micro-agent ./file-to-edit.ts -m 20`. 175 | 176 | ### Config 177 | 178 | You can configure the CLI with the `config` command, for instance to set your OpenAI API key: 179 | 180 | ```bash 181 | micro-agent config set OPENAI_KEY= 182 | ``` 183 | 184 | or to set an Anthropic key: 185 | 186 | ```bash 187 | micro-agent config set ANTHROPIC_KEY= 188 | ``` 189 | 190 | By default Micro Agent uses `gpt-4o` as the model, but you can override it with the `MODEL` config option (or environment variable): 191 | 192 | ```bash 193 | micro-agent config set MODEL=gpt-3.5-turbo 194 | ``` 195 | 196 | or, if you supply an Anthropic key, you can use any Claude model. by default `claude` is an alias to `claude-3-5-sonnet-20241022`: 197 | 198 | ```bash 199 | micro-agent config set MODEL=claude 200 | ``` 201 | 202 | #### Config UI 203 | 204 | To use a more visual interface to view and set config options you can type: 205 | 206 | ```bash 207 | micro-agent config 208 | ``` 209 | 210 | To get an interactive UI like below: 211 | 212 | ```bash 213 | ◆ Set config: 214 | │ ○ OpenAI Key 215 | │ ○ Anthropic Key 216 | │ ○ OpenAI API Endpoint 217 | │ ● Model (gpt-4o) 218 | │ ○ Done 219 | └ 220 | ``` 221 | 222 | #### Environment variables 223 | 224 | All config options can be overridden as environment variables, for instance: 225 | 226 | ```bash 227 | MODEL=gpt-3.5-turbo micro-agent ./file-to-edit.ts -t "npm test" 228 | ``` 229 | 230 | ### Upgrading 231 | 232 | Check the installed version with: 233 | 234 | ```bash 235 | micro-agent --version 236 | ``` 237 | 238 | If it's not the [latest version](https://github.com/BuilderIO/micro-agent/tags), run: 239 | 240 | ```bash 241 | micro-agent update 242 | ``` 243 | 244 | Or manually update with: 245 | 246 | ```bash 247 | npm update -g @builder.io/micro-agent 248 | ``` 249 | 250 | ## Contributing 251 | 252 | We would love your contributions to make this project better, and gladly accept PRs. Please see [./CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute. 253 | 254 | If you are looking for a good first issue, check out the [good first issue](https://github.com/BuilderIO/micro-agent/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) label. 255 | 256 | ## Feedback 257 | 258 | If you have any feedback, please open an issue or @ me at [@steve8708](https://twitter.com/steve8708) on Twitter. 259 | 260 | ## Usage 261 | 262 | ```bash 263 | Usage: 264 | micro-agent [flags...] 265 | micro-agent 266 | 267 | Commands: 268 | config Configure the CLI 269 | update Update Micro Agent to the latest version 270 | 271 | Flags: 272 | -h, --help Show help 273 | -m, --max-runs The maximum number of runs to attempt 274 | -p, --prompt Prompt to run 275 | -t, --test The test script to run 276 | -f, --test-file The test file to run 277 | -v, --visual Visual matching URL 278 | --thread Thread ID to resume 279 | --version Show version 280 | ``` 281 | 282 |

283 | 284 |

285 | 286 | 287 | 288 | Made with love by Builder.io 289 | 290 | 291 |

292 | -------------------------------------------------------------------------------- /src/helpers/llm.ts: -------------------------------------------------------------------------------- 1 | import OpenAI, { AzureOpenAI } from 'openai'; 2 | import { getConfig } from './config'; 3 | import { KnownError } from './error'; 4 | import { commandName } from './constants'; 5 | import { systemPrompt } from './systemPrompt'; 6 | import { RunCreateParams } from 'openai/resources/beta/threads/runs/runs'; 7 | import { RunOptions } from './run'; 8 | import { log } from '@clack/prompts'; 9 | import { green } from 'kolorist'; 10 | import { formatMessage } from './test'; 11 | import { removeBackticks } from './remove-backticks'; 12 | import ollama from 'ollama'; 13 | import dedent from 'dedent'; 14 | import { removeInitialSlash } from './remove-initial-slash'; 15 | import { captureLlmRecord, mockedLlmCompletion } from './mock-llm'; 16 | import { getCodeBlock } from './interactive-mode'; 17 | import Anthropic from '@anthropic-ai/sdk'; 18 | 19 | const defaultModel = 'gpt-4o'; 20 | const assistantIdentifierMetadataKey = '_id'; 21 | const assistantIdentifierMetadataValue = '@builder.io/micro-agent'; 22 | 23 | const useOllama = (model?: string) => { 24 | return model?.includes('llama') || model?.includes('phi'); 25 | }; 26 | 27 | const useAnthropic = (model?: string) => { 28 | return model?.includes('claude'); 29 | }; 30 | 31 | const supportsFunctionCalling = (model?: string) => { 32 | return !!{ 33 | 'gpt-4o': true, 34 | 'gpt-4o-2024-05-13': true, 35 | 'gpt-4-turbo': true, 36 | 'gpt-4-turbo-2024-04-09': true, 37 | 'gpt-4-turbo-preview': true, 38 | 'gpt-4-0125-preview': true, 39 | 'gpt-4-1106-preview': true, 40 | 'gpt-4': true, 41 | 'gpt-4-0613': true, 42 | 'gpt-3.5-turbo': true, 43 | 'gpt-3.5-turbo-0125': true, 44 | 'gpt-3.5-turbo-1106': true, 45 | 'gpt-3.5-turbo-0613': true, 46 | }[model || '']; 47 | }; 48 | 49 | export const getOpenAi = async function () { 50 | const { OPENAI_KEY: openaiKey, OPENAI_API_ENDPOINT: endpoint } = 51 | await getConfig(); 52 | if (!openaiKey) { 53 | throw new KnownError( 54 | `Missing OpenAI key. Use \`${commandName} config\` to set it.` 55 | ); 56 | } 57 | if (endpoint.indexOf('.openai.azure.com/openai') > 0) { 58 | const deploymentName = endpoint.split('/deployments/')[1].split('/')[0]; 59 | const apiVersion = endpoint.split('api-version=')[1]; 60 | if (!deploymentName || !apiVersion) { 61 | throw new KnownError( 62 | `Invalid Azure OpenAI endpoint. Use \`${commandName} config\` to set ` + 63 | 'your Azure OpenAI deployment endpoint as your OpenAI API endpoint. ' + 64 | 'Use the format: https://.openai.azure.com/openai/' + 65 | 'deployments//chat/completions?api-version=' 66 | ); 67 | } 68 | return new AzureOpenAI({ 69 | apiKey: openaiKey, 70 | endpoint: endpoint, 71 | deployment: `/deployments/${deploymentName}`, 72 | apiVersion: apiVersion, 73 | }); 74 | } else { 75 | return new OpenAI({ 76 | apiKey: openaiKey, 77 | baseURL: endpoint, 78 | }); 79 | } 80 | }; 81 | 82 | export const getAnthropic = async function () { 83 | const { ANTHROPIC_KEY: anthropicKey } = await getConfig(); 84 | if (!anthropicKey) { 85 | throw new KnownError( 86 | `Missing Anthropic key. Use \`${commandName} config\` to set it.` 87 | ); 88 | } 89 | const anthropic = new Anthropic({ 90 | apiKey: anthropicKey, 91 | }); 92 | return anthropic; 93 | }; 94 | 95 | export const getFileSuggestion = async function ( 96 | prompt: string, 97 | fileString: string 98 | ) { 99 | const message = { 100 | role: 'user' as const, 101 | content: dedent` 102 | Please give me a recommended file path for the following prompt: 103 | 104 | ${prompt} 105 | 106 | 107 | Here is a preview of the files in the current directory for reference. Please 108 | use these as a reference as to what a good file name, language, and path would be 109 | to match the other files in the project given the naming/folder/language conventions. 110 | 111 | For instance, if the other files are in TypeScript, the file path should end in .ts. 112 | And if the most common casing in the file tree is dash-case, the file path should be dash-case. 113 | 114 | ${fileString} 115 | 116 | 117 | 118 | `, 119 | }; 120 | const { MODEL: model } = await getConfig(); 121 | if ( 122 | useOllama(model) || 123 | useAnthropic(model) || 124 | !supportsFunctionCalling(model) 125 | ) { 126 | return removeInitialSlash( 127 | removeBackticks( 128 | await getSimpleCompletion({ 129 | messages: [ 130 | { 131 | role: 'system' as const, 132 | content: 133 | 'You are an assistant that given a snapshot of the current filesystem suggests a relative file path for the code algorithm mentioned in the prompt. No other words, just one file path', 134 | }, 135 | message, 136 | ], 137 | }) 138 | ) 139 | ); 140 | } 141 | const openai = await getOpenAi(); 142 | const completion = await openai.chat.completions.create({ 143 | model: model || defaultModel, 144 | tool_choice: { 145 | type: 'function', 146 | function: { name: 'file_suggestion' }, 147 | }, 148 | tools: [ 149 | { 150 | type: 'function', 151 | function: { 152 | name: 'file_suggestion', 153 | description: 154 | 'Given a prompt and a list of files, suggest a file path', 155 | parameters: { 156 | type: 'object', 157 | properties: { 158 | filePath: { 159 | type: 'string', 160 | description: 161 | 'Relative file path to the file that the code algorithm should be written in, in case of doubt the extension should be .js', 162 | }, 163 | }, 164 | required: ['filePath'], 165 | }, 166 | }, 167 | }, 168 | ], 169 | messages: [ 170 | { 171 | role: 'system' as const, 172 | content: 173 | 'You are an assistant that given a snapshot of the current filesystem suggests a relative file path for the code algorithm mentioned in the prompt.', 174 | }, 175 | message, 176 | ], 177 | }); 178 | const jsonStr = 179 | completion.choices[0]?.message.tool_calls?.[0]?.function.arguments; 180 | if (!jsonStr) { 181 | return 'src/algorithm.js'; 182 | } 183 | return removeInitialSlash(JSON.parse(jsonStr).filePath); 184 | }; 185 | 186 | export const getSimpleCompletion = async function (options: { 187 | messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[]; 188 | onChunk?: (chunk: string) => void; 189 | }) { 190 | const { 191 | MODEL: model, 192 | MOCK_LLM_RECORD_FILE: mockLlmRecordFile, 193 | USE_MOCK_LLM: useMockLlm, 194 | } = await getConfig(); 195 | 196 | if (useMockLlm) { 197 | return mockedLlmCompletion(mockLlmRecordFile, options.messages); 198 | } 199 | 200 | if (useAnthropic(model)) { 201 | let output = ''; 202 | const anthropic = await getAnthropic(); 203 | const systemMessage = options.messages.find( 204 | (message) => message.role === 'system' 205 | ); 206 | const result = anthropic.messages 207 | .stream({ 208 | model: 209 | (model as string) === 'claude' ? 'claude-3-5-sonnet-20241022' : model, 210 | max_tokens: 4096, 211 | system: systemMessage?.content as string, 212 | messages: options.messages.filter( 213 | (message) => message.role !== 'system' 214 | ) as any[], 215 | }) 216 | .on('text', (text) => { 217 | output += text; 218 | if (options.onChunk) { 219 | options.onChunk(text); 220 | } 221 | }); 222 | 223 | await result.done(); 224 | captureLlmRecord(options.messages, output, mockLlmRecordFile); 225 | return output; 226 | } 227 | 228 | if (useOllama(model)) { 229 | const response = await ollama.chat({ 230 | model: model, 231 | messages: options.messages as any[], 232 | stream: true, 233 | }); 234 | 235 | let output = ''; 236 | 237 | for await (const chunk of response) { 238 | output += chunk.message.content; 239 | if (options.onChunk) { 240 | options.onChunk(chunk.message.content); 241 | } 242 | } 243 | captureLlmRecord(options.messages, output, mockLlmRecordFile); 244 | 245 | return output; 246 | } 247 | const openai = await getOpenAi(); 248 | const completion = await openai.chat.completions.create({ 249 | model: model || defaultModel, 250 | messages: options.messages, 251 | temperature: 0, 252 | seed: 42, 253 | stream: true, 254 | }); 255 | 256 | let output = ''; 257 | 258 | for await (const chunk of completion) { 259 | const str = chunk.choices[0]?.delta.content; 260 | if (str) { 261 | output += str; 262 | if (options.onChunk) { 263 | options.onChunk(str); 264 | } 265 | } 266 | } 267 | 268 | captureLlmRecord(options.messages, output, mockLlmRecordFile); 269 | 270 | return output; 271 | }; 272 | 273 | export const getCompletion = async function (options: { 274 | messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[]; 275 | options: RunOptions; 276 | useAssistant?: boolean; 277 | }) { 278 | const { 279 | MODEL: model, 280 | MOCK_LLM_RECORD_FILE: mockLlmRecordFile, 281 | USE_MOCK_LLM: useMockLlm, 282 | OPENAI_API_ENDPOINT: endpoint, 283 | USE_ASSISTANT, 284 | } = await getConfig(); 285 | if (useMockLlm) { 286 | return mockedLlmCompletion(mockLlmRecordFile, options.messages); 287 | } 288 | 289 | const useModel = model || defaultModel; 290 | const useOllamaChat = useOllama(useModel); 291 | 292 | if (useAnthropic(useModel)) { 293 | process.stdout.write(formatMessage('\n')); 294 | const output = await getSimpleCompletion({ 295 | messages: options.messages, 296 | onChunk: (chunk) => { 297 | process.stderr.write(formatMessage(chunk)); 298 | }, 299 | }); 300 | process.stdout.write(formatMessage('\n')); 301 | return output; 302 | } 303 | 304 | if (useOllamaChat) { 305 | process.stdout.write(formatMessage('\n')); 306 | const output = await getSimpleCompletion({ 307 | messages: options.messages, 308 | onChunk: (chunk) => { 309 | process.stderr.write(formatMessage(chunk)); 310 | }, 311 | }); 312 | process.stdout.write(formatMessage('\n')); 313 | return output; 314 | } 315 | const openai = await getOpenAi(); 316 | 317 | if (options.useAssistant ?? (USE_ASSISTANT && !endpoint)) { 318 | let assistantId: string; 319 | const assistants = await openai.beta.assistants.list({ 320 | limit: 100, 321 | }); 322 | const assistant = assistants.data.find( 323 | (assistant) => 324 | (assistant.metadata as any)?.[assistantIdentifierMetadataKey] === 325 | assistantIdentifierMetadataValue 326 | ); 327 | if (assistant) { 328 | assistantId = assistant.id; 329 | } else { 330 | const assistant = await openai.beta.assistants.create({ 331 | name: 'Micro Agent', 332 | model: useModel, 333 | metadata: { 334 | [assistantIdentifierMetadataKey]: assistantIdentifierMetadataValue, 335 | }, 336 | }); 337 | assistantId = assistant.id; 338 | } 339 | let threadId = options.options.threadId; 340 | if (!threadId) { 341 | const thread = await openai.beta.threads.create(); 342 | threadId = thread.id; 343 | log.info(`Created thread: ${green(threadId)}`); 344 | } 345 | options.options.threadId = threadId; 346 | 347 | process.stdout.write(formatMessage('\n')); 348 | 349 | let result = ''; 350 | return new Promise((resolve) => { 351 | openai.beta.threads.runs 352 | .stream(threadId, { 353 | instructions: systemPrompt, 354 | assistant_id: assistantId, 355 | additional_messages: options.messages.filter( 356 | (message) => message.role !== 'system' 357 | ) as RunCreateParams.AdditionalMessage[], 358 | }) 359 | .on('textDelta', (textDelta) => { 360 | const str = textDelta.value || ''; 361 | if (str) { 362 | result += textDelta.value; 363 | process.stderr.write(formatMessage(str)); 364 | } 365 | }) 366 | .on('textDone', () => { 367 | process.stdout.write('\n'); 368 | const output = getCodeBlock(result); 369 | captureLlmRecord(options.messages, output, mockLlmRecordFile); 370 | resolve(output); 371 | }); 372 | }); 373 | } else { 374 | const completion = await openai.chat.completions.create({ 375 | model: model || defaultModel, 376 | messages: options.messages, 377 | stream: true, 378 | }); 379 | let output = ''; 380 | process.stdout.write(formatMessage('\n')); 381 | for await (const chunk of completion) { 382 | const str = chunk.choices[0]?.delta.content; 383 | if (str) { 384 | output += str; 385 | process.stderr.write(formatMessage(str)); 386 | } 387 | } 388 | process.stdout.write('\n'); 389 | captureLlmRecord(options.messages, output, mockLlmRecordFile); 390 | 391 | return output; 392 | } 393 | }; 394 | -------------------------------------------------------------------------------- /test/fixtures/add.json: -------------------------------------------------------------------------------- 1 | { 2 | "completions": [ 3 | { 4 | "inputs": [ 5 | { 6 | "role": "system", 7 | "content": "You are an assistant that given a snapshot of the current filesystem suggests a relative file path for the code algorithm mentioned in the prompt. No other words, just one file path" 8 | }, 9 | { 10 | "role": "user", 11 | "content": "Please give me a recommended file path for the following prompt:\n\nAdds numbers together\n\n\nHere is a preview of the files in the current directory for reference. Please\nuse these as a reference as to what a good file name and path would be:\n\ntest/nextjs-app/tsconfig.json\ntest/nextjs-app/tailwind.config.ts\ntest/nextjs-app/public\ntest/nextjs-app/postcss.config.mjs\ntest/nextjs-app/package.json\ntest/nextjs-app/package-lock.json\ntest/nextjs-app/next.config.mjs\ntest/nextjs-app/app\ntest/nextjs-app/README.md\nsrc/tests/ternary\nsrc/tests/angular-parser\nsrc/images/original-label.png\nsrc/images/my-version-label.png\nsrc/helpers/visual-test.ts\nsrc/helpers/visual-generate.ts\nsrc/helpers/test.ts\nsrc/helpers/systemPrompt.ts\nsrc/helpers/run.ts\nsrc/helpers/remove-backticks.ts\nsrc/helpers/remove-backticks.test.ts\nsrc/helpers/openai.test.ts\nsrc/helpers/llm.ts\nsrc/helpers/iterate-on-test.ts\nsrc/helpers/interactive-mode.ts\nsrc/helpers/get-screenshot.ts\nsrc/helpers/generate.ts\nsrc/helpers/find-visual-file.ts\nsrc/helpers/file-exists.ts\nsrc/helpers/exit-on-cancel.ts\nsrc/helpers/error.ts\nsrc/helpers/constants.ts\nsrc/helpers/config.ts\nsrc/helpers/config.test.ts\nsrc/helpers/base64.ts\nsrc/helpers/apply-unified-diff.ts\nsrc/helpers/apply-unified-diff.test.ts\nsrc/commands/update.ts\nsrc/commands/config.ts\n" 12 | } 13 | ], 14 | "output": "src/helpers/add-numbers.ts" 15 | }, 16 | { 17 | "inputs": [ 18 | { 19 | "role": "system", 20 | "content": "You are an AI assistant that given a user prompt" 21 | }, 22 | { 23 | "role": "user", 24 | "content": "Please prepare a unit test file (can be multiple tests) for the following prompt:\n \n Adds numbers together\n \n\n The test will be located at `test/add.test.ts` and the code to test will be located at\n `test/add.ts`.\n\n Here is a copy of a couple example tests in the repo:\n \n import { test, expect } from 'vitest';\nimport { removeBackticks } from './remove-backticks';\n\n// Remove backticks from a string, for instance to remove the\n// markdown backticks (+ language name) around code returned that\n// should just be that code\ntest('should remove backticks', () => {\nexpect(removeBackticks('```\nhello\n```')).toBe('hello');\nexpect(removeBackticks('```typescript\nhello\nworld\n```')).toBe(\n'hello\nworld'\n);\nexpect(removeBackticks('```js\nhello\nworld\n```')).toBe('hello\nworld');\n});\n\nimport { getOpenAi, getSimpleCompletion } from './llm';\nimport { KnownError } from './error';\nimport { expect, describe, it, vi } from 'vitest';\nimport OpenAI from 'openai';\nimport { ChatCompletionMessageParam } from 'openai/resources';\n\nconst mocks = vi.hoisted(() => {\nreturn {\nopenAIConstructor: vi.fn(),\ngetConfig: vi.fn(),\ncreate: vi.fn(),\n};\n});\n\nvi.mock('./config', () => {\nreturn {\ngetConfig: mocks.getConfig,\n};\n});\n\nvi.mock('openai', () => {\nreturn {\ndefault: mocks.openAIConstructor,\n};\n});\n\nmocks.openAIConstructor.mockImplementation(() => {\nreturn {\nchat: {\n completions: {\n create: mocks.create,\n },\n},\n};\n});\n\nconst defaultConfig = {\nOPENAI_KEY: 'my-openai-key',\nOPENAI_API_ENDPOINT: 'https://api.openai.com/v1',\n};\n\ndescribe('getOpenAi', () => {\nit('should throw a KnownError if OPENAI_KEY is blank', async () => {\nmocks.getConfig\n .mockResolvedValueOnce({ OPENAI_KEY: '' })\n .mockResolvedValueOnce({ OPENAI_KEY: '' });\n\nawait expect(getOpenAi()).rejects.toThrow(KnownError);\nawait expect(getOpenAi()).rejects.toThrow(\n 'Missing OpenAI key. Use `micro-agent config` to set it.'\n);\n});\n\nit('should create a new OpenAI instance with the provided key and endpoint', async () => {\nmocks.getConfig.mockResolvedValueOnce(defaultConfig);\n\nawait getOpenAi();\n\nexpect(OpenAI).toHaveBeenCalledWith({\n apiKey: 'my-openai-key',\n baseURL: 'https://api.openai.com/v1',\n});\n});\n});\n\ndescribe('getSimpleCompletion', () => {\nit('should call openai.chat.completions.create with the correct parameters', async () => {\nmocks.getConfig\n .mockResolvedValueOnce(defaultConfig)\n .mockResolvedValueOnce(defaultConfig);\nmocks.create.mockResolvedValueOnce([]);\n\nconst messages: ChatCompletionMessageParam[] = [\n { role: 'system', content: 'Hello' },\n];\nawait getSimpleCompletion({ messages });\n\nexpect(mocks.create).toHaveBeenCalledWith({\n model: 'gpt-4o',\n messages,\n stream: true,\n});\n});\n\nit('should concatenate the output from completion chunks', async () => {\nmocks.getConfig\n .mockResolvedValueOnce(defaultConfig)\n .mockResolvedValueOnce(defaultConfig);\nmocks.create.mockResolvedValueOnce([\n { choices: [{ delta: { content: 'Hello' } }] },\n { choices: [{ delta: { content: ' World' } }] },\n]);\n\nconst messages: ChatCompletionMessageParam[] = [\n { role: 'system', content: 'Hello' },\n];\nconst output = await getSimpleCompletion({ messages });\n\nexpect(output).toBe('Hello World');\n});\n\nit('should call options.onChunk for each chunk', async () => {\nmocks.getConfig\n .mockResolvedValueOnce(defaultConfig)\n .mockResolvedValueOnce(defaultConfig);\nmocks.create.mockResolvedValueOnce([\n { choices: [{ delta: { content: 'Hello' } }] },\n { choices: [{ delta: { content: ' World' } }] },\n]);\n\nconst messages: ChatCompletionMessageParam[] = [\n { role: 'system', content: 'Hello' },\n];\nconst onChunk = vi.fn();\nconst output = await getSimpleCompletion({ messages, onChunk });\n\nexpect(onChunk).toHaveBeenCalledTimes(2);\nexpect(onChunk).toHaveBeenCalledWith('Hello');\nexpect(onChunk).toHaveBeenCalledWith(' World');\n});\n});\n\n " 25 | } 26 | ], 27 | "output": "```typescript\nimport { test, expect } from 'vitest';\nimport { add } from './add';\n\ntest('should add two positive numbers', () => {\n expect(add(2, 3)).toBe(5);\n});\n\ntest('should add two negative numbers', () => {\n expect(add(-2, -3)).toBe(-5);\n});\n\ntest('should add a positive and a negative number', () => {\n expect(add(2, -3)).toBe(-1);\n});\n\ntest('should add zero to a number', () => {\n expect(add(0, 5)).toBe(5);\n expect(add(5, 0)).toBe(5);\n});\n\ntest('should add decimal numbers', () => {\n expect(add(2.5, 3.2)).toBeCloseTo(5.7, 5);\n expect(add(-2.5, -3.2)).toBeCloseTo(-5.7, 5);\n});\n\ntest('should handle large numbers', () => {\n expect(add(1e12, 1e12)).toBe(2e12);\n});\n```" 28 | }, 29 | { 30 | "inputs": [ 31 | { 32 | "role": "system", 33 | "content": "You take a prompt and existing unit tests and generate the function" 34 | }, 35 | { 36 | "role": "user", 37 | "content": "Here is what I need:\n\n \n Pass the tests\n \n\n The current code is:\n \n None\n \n\n The file path for the above is test/calc.ts.\n\n The test code that needs to pass is:\n \n import { test, expect } from 'vitest';\nimport { add } from './calc';\n\ntest('should add two positive numbers', () => {\nexpect(add(2, 3)).toBe(5);\n});\n\ntest('should add two negative numbers', () => {\nexpect(add(-2, -3)).toBe(-5);\n});\n\ntest('should add a positive and a negative number', () => {\nexpect(add(2, -3)).toBe(-1);\n});\n\ntest('should add zero to a number', () => {\nexpect(add(0, 5)).toBe(5);\nexpect(add(5, 0)).toBe(5);\n});\n\ntest('should add decimal numbers', () => {\nexpect(add(2.5, 3.2)).toBeCloseTo(5.7, 5);\nexpect(add(-2.5, -3.2)).toBeCloseTo(-5.7, 5);\n});\n\ntest('should handle large numbers', () => {\nexpect(add(1e12, 1e12)).toBe(2e12);\n});\n \n\n The file path for the test is test/calc.test.ts.\n\n The error you received on that code was:\n \n filter: add\ninclude: **/*.{test,spec}.?(c|m)[jt]s?(x)\nexclude: **/node_modules/**, **/dist/**, **/cypress/**, **/.{idea,git,cache,output,temp}/**, **/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*\nwatch exclude: **/node_modules/**, **/dist/**\n\nNo test files found, exiting with code 1\n \n\n Don't use any node modules that aren't included here unless specifically told otherwise:\n\n{\n\"name\": \"@builder.io/micro-agent\",\n\"description\": \"An AI CLI that writes code for you.\",\n\"version\": \"0.0.26\",\n\"type\": \"module\",\n\"dependencies\": {\n\"@anthropic-ai/sdk\": \"^0.21.1\",\n\"@clack/core\": \"latest\",\n\"@clack/prompts\": \"latest\",\n\"@commitlint/cli\": \"^19.3.0\",\n\"@commitlint/config-conventional\": \"^19.2.2\",\n\"@dqbd/tiktoken\": \"^1.0.15\",\n\"@types/diff\": \"^5.2.1\",\n\"@types/probe-image-size\": \"^7.2.4\",\n\"cleye\": \"^1.3.2\",\n\"dedent\": \"^0.7.0\",\n\"diff\": \"^5.2.0\",\n\"execa\": \"^9.1.0\",\n\"glob\": \"^10.4.1\",\n\"ini\": \"^4.1.3\",\n\"kolorist\": \"^1.7.0\",\n\"ollama\": \"^0.5.1\",\n\"openai\": \"^4.47.1\",\n\"playwright\": \"^1.44.1\",\n\"probe-image-size\": \"^7.2.3\",\n\"sharp\": \"^0.33.4\"\n},\n\"repository\": {\n\"type\": \"git\",\n\"url\": \"https://github.com/BuilderIO/micro-agent\"\n},\n\"files\": [\n\"dist\"\n],\n\"bin\": {\n\"micro-agent\": \"./dist/cli.mjs\",\n\"ma\": \"./dist/cli.mjs\"\n},\n\"scripts\": {\n\"test\": \"vitest run\",\n\"start\": \"jiti ./src/cli.ts\",\n\"lint:fix\": \"prettier --write . && eslint --fix\",\n\"lint\": \"prettier --check . && eslint\",\n\"typecheck\": \"tsc\",\n\"build\": \"pkgroll\",\n\"release:patch\": \"npm run build && npm version patch && npm run build && npm publish && git push --follow-tags && standard-version --release-as patch\",\n\"release\": \"standard-version\",\n\"release:minor\": \"standard-version --release-as minor\",\n\"release:major\": \"standard-version --release-as major\",\n\"postinstall\": \"npx playwright install\",\n\"prepare\": \"husky install\"\n},\n\"devDependencies\": {\n\"@types/dedent\": \"^0.7.0\",\n\"@types/ini\": \"^1.3.31\",\n\"@types/node\": \"^18.15.11\",\n\"@typescript-eslint/eslint-plugin\": \"^5.57.1\",\n\"@typescript-eslint/parser\": \"^5.57.1\",\n\"eslint\": \"^8.38.0\",\n\"eslint-plugin-unused-imports\": \"^2.0.0\",\n\"husky\": \"^8.0.0\",\n\"jiti\": \"^1.21.3\",\n\"pkgroll\": \"^1.9.0\",\n\"prettier\": \"^2.8.8\",\n\"standard-version\": \"^9.5.0\",\n\"typescript\": \"^4.9.5\",\n\"vitest\": \"^1.6.0\"\n}\n}\n\n\n\n Please give me the code that satisfies the prompt and test.\n\n Be sure to use good coding conventions. For instance, if you are generating a typescript\n file, use types (e.g. for function parameters, etc)." 38 | } 39 | ], 40 | "output": "const add = (a: number, b: number): number => {\n return a + b;\n};\n\nexport { add };" 41 | }, 42 | { 43 | "inputs": [ 44 | { 45 | "role": "system", 46 | "content": "You are an AI assistant that given a user prompt" 47 | }, 48 | { 49 | "role": "user", 50 | "content": "Please prepare a unit test file (can be multiple tests) for the following prompt:\n \n Adds numbers together\n \n\n The test will be located at `test/add.test.ts` and the code to test will be located at\n `test/add.ts`.\n\n Here is a copy of a couple example tests in the repo:\n \n import { test, expect } from 'vitest';\nimport { removeBackticks } from './remove-backticks';\n\n// Remove backticks from a string, for instance to remove the\n// markdown backticks (+ language name) around code returned that\n// should just be that code\ntest('should remove backticks', () => {\nexpect(removeBackticks('```\nhello\n```')).toBe('hello');\nexpect(removeBackticks('```typescript\nhello\nworld\n```')).toBe(\n'hello\nworld'\n);\nexpect(removeBackticks('```js\nhello\nworld\n```')).toBe('hello\nworld');\n});\n\nimport { getOpenAi, getSimpleCompletion } from './llm';\nimport { KnownError } from './error';\nimport { expect, describe, it, vi } from 'vitest';\nimport OpenAI from 'openai';\nimport { ChatCompletionMessageParam } from 'openai/resources';\n\nconst mocks = vi.hoisted(() => {\nreturn {\nopenAIConstructor: vi.fn(),\ngetConfig: vi.fn(),\ncreate: vi.fn(),\n};\n});\n\nvi.mock('./config', () => {\nreturn {\ngetConfig: mocks.getConfig,\n};\n});\n\nvi.mock('openai', () => {\nreturn {\ndefault: mocks.openAIConstructor,\n};\n});\n\nmocks.openAIConstructor.mockImplementation(() => {\nreturn {\nchat: {\n completions: {\n create: mocks.create,\n },\n},\n};\n});\n\nconst defaultConfig = {\nOPENAI_KEY: 'my-openai-key',\nOPENAI_API_ENDPOINT: 'https://api.openai.com/v1',\n};\n\ndescribe('getOpenAi', () => {\nit('should throw a KnownError if OPENAI_KEY is blank', async () => {\nmocks.getConfig\n .mockResolvedValueOnce({ OPENAI_KEY: '' })\n .mockResolvedValueOnce({ OPENAI_KEY: '' });\n\nawait expect(getOpenAi()).rejects.toThrow(KnownError);\nawait expect(getOpenAi()).rejects.toThrow(\n 'Missing OpenAI key. Use `micro-agent config` to set it.'\n);\n});\n\nit('should create a new OpenAI instance with the provided key and endpoint', async () => {\nmocks.getConfig.mockResolvedValueOnce(defaultConfig);\n\nawait getOpenAi();\n\nexpect(OpenAI).toHaveBeenCalledWith({\n apiKey: 'my-openai-key',\n baseURL: 'https://api.openai.com/v1',\n});\n});\n});\n\ndescribe('getSimpleCompletion', () => {\nit('should call openai.chat.completions.create with the correct parameters', async () => {\nmocks.getConfig\n .mockResolvedValueOnce(defaultConfig)\n .mockResolvedValueOnce(defaultConfig);\nmocks.create.mockResolvedValueOnce([]);\n\nconst messages: ChatCompletionMessageParam[] = [\n { role: 'system', content: 'Hello' },\n];\nawait getSimpleCompletion({ messages });\n\nexpect(mocks.create).toHaveBeenCalledWith({\n model: 'gpt-4o',\n messages,\n stream: true,\n});\n});\n\nit('should concatenate the output from completion chunks', async () => {\nmocks.getConfig\n .mockResolvedValueOnce(defaultConfig)\n .mockResolvedValueOnce(defaultConfig);\nmocks.create.mockResolvedValueOnce([\n { choices: [{ delta: { content: 'Hello' } }] },\n { choices: [{ delta: { content: ' World' } }] },\n]);\n\nconst messages: ChatCompletionMessageParam[] = [\n { role: 'system', content: 'Hello' },\n];\nconst output = await getSimpleCompletion({ messages });\n\nexpect(output).toBe('Hello World');\n});\n\nit('should call options.onChunk for each chunk', async () => {\nmocks.getConfig\n .mockResolvedValueOnce(defaultConfig)\n .mockResolvedValueOnce(defaultConfig);\nmocks.create.mockResolvedValueOnce([\n { choices: [{ delta: { content: 'Hello' } }] },\n { choices: [{ delta: { content: ' World' } }] },\n]);\n\nconst messages: ChatCompletionMessageParam[] = [\n { role: 'system', content: 'Hello' },\n];\nconst onChunk = vi.fn();\nconst output = await getSimpleCompletion({ messages, onChunk });\n\nexpect(onChunk).toHaveBeenCalledTimes(2);\nexpect(onChunk).toHaveBeenCalledWith('Hello');\nexpect(onChunk).toHaveBeenCalledWith(' World');\n});\n});\n\n " 51 | } 52 | ], 53 | "output": "```typescript\nimport { test, expect, describe } from 'vitest';\nimport { add } from './add';\n\ndescribe('add', () => {\n it('should add two positive numbers', () => {\n expect(add(2, 3)).toBe(5);\n });\n\n it('should add two negative numbers', () => {\n expect(add(-2, -3)).toBe(-5);\n });\n\n it('should add a positive and a negative number', () => {\n expect(add(2, -3)).toBe(-1);\n });\n\n it('should add zero to a number', () => {\n expect(add(0, 5)).toBe(5);\n expect(add(5, 0)).toBe(5);\n });\n\n it('should add decimal numbers', () => {\n expect(add(2.5, 3.2)).toBeCloseTo(5.7, 5);\n expect(add(-2.5, -3.2)).toBeCloseTo(-5.7, 5);\n });\n\n it('should handle large numbers', () => {\n expect(add(1e12, 1e12)).toBe(2e12);\n });\n})\n\n```" 54 | } 55 | ] 56 | } 57 | --------------------------------------------------------------------------------