├── instructions ├── .beads │ ├── issues.jsonl │ ├── metadata.json │ ├── .gitignore │ ├── config.yaml │ └── README.md ├── .gitattributes ├── HELLO_WORLD.md ├── CHECK_ACTIONS.md ├── README.md ├── SETUP_NPM_PUBLISHING.md └── CREATE_INSTRUCTIONS.md ├── test └── fixtures │ ├── imports │ ├── nested-inner.md │ ├── glob-test │ │ ├── a.ts │ │ ├── b.ts │ │ └── c.js │ ├── helper.md │ ├── nested-outer.md │ ├── lines.txt │ └── types.ts │ ├── file-import.claude.md │ ├── nested-import.claude.md │ ├── command-substitution.claude.md │ ├── line-range.claude.md │ ├── glob-import.claude.md │ ├── simple.claude.md │ ├── symbol-extraction.claude.md │ ├── template-vars.claude.md │ └── complex-combined.claude.md ├── .beads ├── beads.db ├── metadata.json ├── .gitignore ├── config.yaml └── README.md ├── .gitattributes ├── smoke-tests ├── test1.codex.md ├── test1.claude.md ├── test1.gemini.md ├── test2.gemini.md ├── test2.claude.md └── test1.copilot.md ├── examples ├── hello.copilot.md ├── hello.claude.md ├── positional-vars.claude.md ├── args-list.claude.md ├── line-range.claude.md ├── subcommand.codex.md ├── symbol-extract.claude.md ├── multi-agent │ ├── architect.claude.md │ ├── implement.claude.md │ ├── security-audit.claude.md │ ├── write-test.claude.md │ ├── audit.claude.md │ ├── announcement.claude.md │ ├── changelog.claude.md │ ├── patch.claude.md │ ├── scaffold.claude.md │ ├── audit-legacy.claude.md │ └── pr-review.claude.md ├── positional-map.copilot.md ├── interactive.i.claude.md ├── env-config.claude.md ├── review.claude.md ├── command-inline.claude.md ├── commit.claude.md ├── optional-flags.claude.md └── template-args.claude.md ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── .releaserc.json ├── .vscode └── settings.json ├── .gitignore ├── src ├── adapters │ ├── claude.ts │ ├── gemini.ts │ ├── opencode.ts │ ├── codex.ts │ ├── droid.ts │ ├── copilot.ts │ └── index.ts ├── spinner.test.ts ├── index.ts ├── spinner.ts ├── imports-injector.ts ├── limits.ts ├── epipe.test.ts ├── logger.test.ts ├── parse.ts ├── concurrency.ts ├── explain.test.ts ├── env.test.ts ├── binary-check.test.ts ├── concurrency.test.ts ├── imports-types.ts ├── crash-log.test.ts ├── failure-menu.test.ts ├── streams.ts ├── errors.ts ├── logger.ts ├── template.ts ├── output-streams.test.ts ├── limits.test.ts ├── schema.test.ts ├── parse.test.ts ├── edit-prompt.ts ├── remote.test.ts ├── cli.test.ts ├── __snapshots__ │ └── snapshot.test.ts.snap ├── env.ts ├── failure-menu.ts ├── stream.ts ├── markdown-renderer.test.ts ├── trust.test.ts ├── smoke-pipe.test.ts ├── imports-parser-fuzz.test.ts ├── edit-prompt.test.ts ├── dry-run.test.ts └── schema.ts ├── tsconfig.json ├── scripts ├── build.ts └── bundle.ts └── package.json /instructions/.beads/issues.jsonl: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/imports/nested-inner.md: -------------------------------------------------------------------------------- 1 | ** Inner nested content ** 2 | -------------------------------------------------------------------------------- /test/fixtures/imports/glob-test/a.ts: -------------------------------------------------------------------------------- 1 | // File a.ts 2 | export const A = "alpha"; 3 | -------------------------------------------------------------------------------- /test/fixtures/imports/glob-test/b.ts: -------------------------------------------------------------------------------- 1 | // File b.ts 2 | export const B = "beta"; 3 | -------------------------------------------------------------------------------- /test/fixtures/imports/helper.md: -------------------------------------------------------------------------------- 1 | This is helper content that will be imported. 2 | -------------------------------------------------------------------------------- /.beads/beads.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnlindquist/mdflow/HEAD/.beads/beads.db -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | 2 | # Use bd merge for beads JSONL files 3 | .beads/issues.jsonl merge=beads 4 | -------------------------------------------------------------------------------- /instructions/.gitattributes: -------------------------------------------------------------------------------- 1 | 2 | # Use bd merge for beads JSONL files 3 | .beads/issues.jsonl merge=beads 4 | -------------------------------------------------------------------------------- /instructions/HELLO_WORLD.md: -------------------------------------------------------------------------------- 1 | --- 2 | command: claude 3 | --- 4 | 5 | Just say "Hello, world!" and nothing else. -------------------------------------------------------------------------------- /test/fixtures/imports/glob-test/c.js: -------------------------------------------------------------------------------- 1 | // File c.js - should NOT be included in *.ts glob 2 | export const C = "gamma"; 3 | -------------------------------------------------------------------------------- /.beads/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "beads.db", 3 | "jsonl_export": "issues.jsonl", 4 | "last_bd_version": "0.27.0" 5 | } -------------------------------------------------------------------------------- /test/fixtures/imports/nested-outer.md: -------------------------------------------------------------------------------- 1 | Outer content starts here. 2 | 3 | @./nested-inner.md 4 | 5 | Outer content ends here. 6 | -------------------------------------------------------------------------------- /instructions/.beads/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "beads.db", 3 | "jsonl_export": "issues.jsonl", 4 | "last_bd_version": "0.27.0" 5 | } -------------------------------------------------------------------------------- /smoke-tests/test1.codex.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Codex smoke test - basic mode 3 | model: o4-mini 4 | --- 5 | 6 | Say only: "Codex smoke test passed" 7 | -------------------------------------------------------------------------------- /smoke-tests/test1.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Claude smoke test - basic print mode 3 | model: haiku 4 | p: true 5 | --- 6 | 7 | Say only: "Claude smoke test passed" 8 | -------------------------------------------------------------------------------- /test/fixtures/file-import.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: opus 3 | --- 4 | 5 | Here is imported content: 6 | 7 | @./imports/helper.md 8 | 9 | End of prompt. 10 | -------------------------------------------------------------------------------- /examples/hello.copilot.md: -------------------------------------------------------------------------------- 1 | # Minimal example - no frontmatter needed for copilot 2 | # Usage: md hello.copilot.md 3 | 4 | Say "Hello from Copilot!" and nothing else. 5 | -------------------------------------------------------------------------------- /smoke-tests/test1.gemini.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Gemini smoke test - basic one-shot mode (uses default model) 3 | command: gemini 4 | --- 5 | 6 | Say only: "Gemini smoke test passed" 7 | -------------------------------------------------------------------------------- /smoke-tests/test2.gemini.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Gemini smoke test - with yolo mode 3 | model: gemini-3-pro-preview 4 | yolo: true 5 | --- 6 | 7 | Say only: "Gemini yolo mode passed" 8 | -------------------------------------------------------------------------------- /test/fixtures/nested-import.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: opus 3 | --- 4 | 5 | Start of nested import test. 6 | 7 | @./imports/nested-outer.md 8 | 9 | End of nested import test. 10 | -------------------------------------------------------------------------------- /smoke-tests/test2.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Claude smoke test - with allowed tools 3 | model: haiku 4 | p: true 5 | allowed-tools: Read 6 | --- 7 | 8 | Say only: "Claude with allowed-tools passed" 9 | -------------------------------------------------------------------------------- /test/fixtures/command-substitution.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: sonnet 3 | --- 4 | 5 | Current date context: 6 | 7 | !`echo "Test command output"` 8 | 9 | Please proceed with the task. 10 | -------------------------------------------------------------------------------- /test/fixtures/line-range.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: sonnet 3 | --- 4 | 5 | Extracting lines 3-7 from the lines file: 6 | 7 | @./imports/lines.txt:3-7 8 | 9 | That's the relevant section. 10 | -------------------------------------------------------------------------------- /examples/hello.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Basic example - command inferred from filename 3 | # Usage: md hello.claude.md 4 | model: sonnet 5 | print: true 6 | --- 7 | 8 | Say "Hello from mdflow!" and nothing else. 9 | -------------------------------------------------------------------------------- /test/fixtures/glob-import.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: sonnet 3 | --- 4 | 5 | Here are all TypeScript files in the glob-test directory: 6 | 7 | @./imports/glob-test/*.ts 8 | 9 | Process these files accordingly. 10 | -------------------------------------------------------------------------------- /examples/positional-vars.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Example: Using positional CLI args as template variables 3 | # Usage: md positional-vars.claude.md "hello" "French" 4 | print: true 5 | --- 6 | Translate "{{ _1 }}" to {{ _2 }}. 7 | -------------------------------------------------------------------------------- /examples/args-list.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Example: Using _args to get all positional args as a numbered list 3 | # Usage: md args-list.claude.md "apple" "banana" "cherry" 4 | print: true 5 | --- 6 | Process these items: 7 | {{ _args }} 8 | -------------------------------------------------------------------------------- /test/fixtures/simple.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: sonnet 3 | --- 4 | 5 | This is a simple markdown file with no imports. 6 | 7 | It has multiple paragraphs and should be preserved exactly. 8 | 9 | - Bullet point 1 10 | - Bullet point 2 11 | - Bullet point 3 12 | -------------------------------------------------------------------------------- /smoke-tests/test1.copilot.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Copilot smoke test 3 | # $1 maps the body to --prompt flag (copilot doesn't accept positional) 4 | command: copilot 5 | model: gpt-4.1 6 | $1: prompt 7 | silent: true 8 | --- 9 | 10 | Say only: "Copilot smoke test passed" 11 | -------------------------------------------------------------------------------- /examples/line-range.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Line range imports - extract specific lines from a file 3 | # Usage: md line-range.claude.md 4 | model: sonnet 5 | print: true 6 | --- 7 | 8 | Explain what this section of the CLI runner does: 9 | 10 | @./src/cli-runner.ts:136-160 11 | -------------------------------------------------------------------------------- /examples/subcommand.codex.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Subcommand - prepend subcommands to CLI args 3 | # Usage: md subcommand.codex.md 4 | # This runs: codex exec --full-auto "..." 5 | _subcommand: exec 6 | full-auto: true 7 | --- 8 | 9 | Analyze this codebase and suggest improvements. 10 | -------------------------------------------------------------------------------- /examples/symbol-extract.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Symbol extraction - extract specific TypeScript symbols 3 | # Usage: md symbol-extract.claude.md 4 | model: sonnet 5 | print: true 6 | --- 7 | 8 | Explain this interface and suggest improvements: 9 | 10 | @./src/types.ts#AgentFrontmatter 11 | -------------------------------------------------------------------------------- /test/fixtures/symbol-extraction.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: sonnet 3 | --- 4 | 5 | Here is the UserProfile interface: 6 | 7 | @./imports/types.ts#UserProfile 8 | 9 | And here is the formatUser function: 10 | 11 | @./imports/types.ts#formatUser 12 | 13 | Use these to implement the feature. 14 | -------------------------------------------------------------------------------- /examples/multi-agent/architect.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # The Optimist - designs TypeScript interfaces 3 | # Usage: md architect.claude.md "User Auth System" | md security-audit.claude.md 4 | model: opus 5 | print: true 6 | --- 7 | Design a TypeScript interface for: {{ _1 }} 8 | Consider scalability and type safety. 9 | -------------------------------------------------------------------------------- /examples/positional-map.copilot.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Map body to a specific CLI flag using $1 3 | # This passes the body as --prompt instead of as a positional arg 4 | # Usage: md positional-map.copilot.md 5 | $1: prompt 6 | model: gpt-4.1 7 | silent: true 8 | --- 9 | 10 | Explain this code in simple terms. 11 | -------------------------------------------------------------------------------- /examples/interactive.i.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Interactive mode - the .i. in filename enables interactive session 3 | # Usage: md interactive.i.claude.md 4 | # This runs: claude "..." (no --print flag, stays in session) 5 | model: sonnet 6 | --- 7 | 8 | Let's have a conversation about this codebase. 9 | 10 | @./src/index.ts 11 | -------------------------------------------------------------------------------- /test/fixtures/imports/lines.txt: -------------------------------------------------------------------------------- 1 | Line 1: Introduction 2 | Line 2: Overview 3 | Line 3: Getting Started 4 | Line 4: Installation 5 | Line 5: Configuration 6 | Line 6: Basic Usage 7 | Line 7: Advanced Features 8 | Line 8: API Reference 9 | Line 9: Troubleshooting 10 | Line 10: FAQ 11 | Line 11: Contributing 12 | Line 12: License 13 | -------------------------------------------------------------------------------- /examples/env-config.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Environment variables (object form) - sets process.env 3 | # These are available to the command and any !`command` inlines 4 | env: 5 | BASE_URL: https://dev.build 6 | DEBUG: true 7 | model: sonnet 8 | print: true 9 | --- 10 | 11 | How do you like my url? !`echo $BASE_URL` 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/review.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Code review with glob imports 3 | # Usage: md review.claude.md 4 | model: opus 5 | print: true 6 | --- 7 | 8 | Review the following code for: 9 | - Bugs and potential issues 10 | - Security vulnerabilities 11 | - Performance problems 12 | - Code style and best practices 13 | 14 | @./src/**/*.ts 15 | -------------------------------------------------------------------------------- /examples/command-inline.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Command inlines - embed shell command output in prompts 3 | # Usage: md command-inline.claude.md 4 | model: sonnet 5 | print: true 6 | --- 7 | 8 | Based on the current git status: 9 | !`git status --short` 10 | 11 | And recent commits: 12 | !`git log --oneline -5` 13 | 14 | What should I work on next? 15 | -------------------------------------------------------------------------------- /examples/multi-agent/implement.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # The Builder - implements code to pass the provided tests 3 | # Usage: md write-test.claude.md "parses cron" | md implement.claude.md 4 | model: sonnet 5 | print: true 6 | --- 7 | Here is a test suite. Write the implementation code that makes these tests pass. 8 | Do not modify the tests. 9 | 10 | {{ _stdin }} 11 | -------------------------------------------------------------------------------- /examples/multi-agent/security-audit.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # The Pessimist - ruthlessly audits design for vulnerabilities 3 | # Usage: md architect.claude.md "Auth System" | md security-audit.claude.md 4 | model: haiku 5 | print: true 6 | --- 7 | Analyze the following code design for security vulnerabilities and edge cases. 8 | Be ruthless. 9 | 10 | Input: 11 | {{ _stdin }} 12 | -------------------------------------------------------------------------------- /test/fixtures/template-vars.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: sonnet 3 | args: 4 | - target 5 | - action 6 | --- 7 | 8 | Please {{ action }} the file at {{ target }}. 9 | 10 | {% if verbose %} 11 | Be verbose in your output and explain each step. 12 | {% endif %} 13 | 14 | {% if dry_run %} 15 | This is a dry run - do not make actual changes. 16 | {% else %} 17 | Apply changes directly to the files. 18 | {% endif %} 19 | -------------------------------------------------------------------------------- /examples/commit.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Generate commit messages from staged changes 3 | # Usage: git diff --staged | md commit.claude.md 4 | model: sonnet 5 | print: true 6 | --- 7 | 8 | Generate a concise, conventional commit message for the following diff. 9 | Use the format: type(scope): description 10 | 11 | Types: feat, fix, docs, style, refactor, test, chore 12 | 13 | Keep it under 72 characters. 14 | 15 | {{ _stdin }} 16 | -------------------------------------------------------------------------------- /examples/multi-agent/write-test.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # The Lead - TDD first step: write comprehensive tests 3 | # Usage: md write-test.claude.md "parses a cron string" | md implement.claude.md 4 | model: opus 5 | print: true 6 | --- 7 | I need a TypeScript function that: {{ _1 }} 8 | 9 | Don't write the implementation. 10 | Write a comprehensive `vitest` test suite that covers edge cases, 11 | happy paths, and error states. 12 | -------------------------------------------------------------------------------- /instructions/CHECK_ACTIONS.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: claude-haiku-4.5 3 | silent: true 4 | allow-tool: 5 | - shell(gh run:*) 6 | interactive: true 7 | --- 8 | 9 | Check GitHub Actions status. Tell me: 10 | 1. Are there any failed runs? If so, what failed? 11 | 2. What's the overall health of the CI pipeline? 12 | 3. Any runs currently in progress? 13 | 14 | Be concise. 15 | 16 | Use the gh cli to dig deeper into the issue. 17 | -------------------------------------------------------------------------------- /examples/optional-flags.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Example: Using --_varname flags without frontmatter declaration 3 | # Usage: md optional-flags.claude.md --_mode detailed 4 | # Or just: md optional-flags.claude.md (will prompt for _mode) 5 | print: true 6 | --- 7 | {% if _mode == "detailed" %} 8 | Provide a detailed, comprehensive analysis. 9 | {% else %} 10 | Provide a brief summary. 11 | {% endif %} 12 | 13 | Analyze this codebase. 14 | -------------------------------------------------------------------------------- /examples/multi-agent/audit.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # The Auditor - security vulnerability scanner 3 | # Usage: md audit.claude.md src/api/user.ts | md patch.claude.md src/api/user.ts 4 | model: opus 5 | print: true 6 | --- 7 | Review this file for security vulnerabilities (XSS, SQLi, sensitive data exposure). 8 | Output a JSON list of issues found with line numbers and descriptions. 9 | If none, output "CLEAN". 10 | 11 | File content: 12 | !`cat {{ _1 }}` 13 | -------------------------------------------------------------------------------- /examples/multi-agent/announcement.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # The Marketer - transforms changelog into engaging announcement 3 | # Usage: md changelog.claude.md v1.0.0 | md announcement.claude.md 4 | model: sonnet 5 | print: true 6 | --- 7 | You are a DevRel expert. Take this technical changelog and write a 8 | punchy, exciting LinkedIn post or Tweet thread announcing the release. 9 | Focus on user value, not just commit messages. 10 | 11 | Input: 12 | {{ _stdin }} 13 | -------------------------------------------------------------------------------- /examples/multi-agent/changelog.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # The Historian - extracts and organizes commits into a changelog 3 | # Usage: md changelog.claude.md v1.0.0 | md announcement.claude.md 4 | # Or: md changelog.claude.md HEAD~10 5 | model: sonnet 6 | print: true 7 | --- 8 | Analyze these commits and group them into "Features", "Fixes", and "Chore". 9 | Ignore merge commits. Output clean Markdown lists. 10 | 11 | !`git log --pretty=format:"%s" {{ _1 }}..HEAD` 12 | -------------------------------------------------------------------------------- /examples/multi-agent/patch.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # The Patcher - fixes vulnerabilities identified by audit 3 | # Usage: md audit.claude.md src/api/user.ts | md patch.claude.md src/api/user.ts 4 | model: sonnet 5 | print: true 6 | --- 7 | Here is the source file: 8 | !`cat {{ _1 }}` 9 | 10 | Here is the security audit: 11 | {{ _stdin }} 12 | 13 | If the audit is "CLEAN", output the original file. 14 | Otherwise, rewrite the code to fix the specific vulnerabilities listed. 15 | Output ONLY the code. 16 | -------------------------------------------------------------------------------- /examples/multi-agent/scaffold.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Scaffolding - generate components based on existing patterns 3 | # Usage: md scaffold.claude.md "DropdownMenu" 4 | model: sonnet 5 | print: true 6 | --- 7 | Read this existing component to understand our pattern: 8 | @./src/components/Button.tsx 9 | 10 | Now, generate a new component named **{{ _1 }}**. 11 | It should have: 12 | 1. The component file 13 | 2. A matching test file 14 | 3. A storybook file 15 | 16 | Output strictly valid code blocks. 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | test: 9 | name: Run Tests 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Bun 16 | uses: oven-sh/setup-bun@v2 17 | 18 | - name: Install dependencies 19 | run: bun install 20 | 21 | - name: Run tests 22 | run: bun test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /examples/template-args.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Template variables with defaults - CLI flags override 3 | # Usage: md template-args.claude.md 4 | # Override: md template-args.claude.md --_feature_name "Payments" --_target_dir "src/billing" 5 | _feature_name: Authentication 6 | _target_dir: src/features 7 | model: sonnet 8 | print: true 9 | --- 10 | 11 | Create a new feature called "{{ _feature_name }}" in {{ _target_dir }}. 12 | 13 | Include: 14 | - A main module file 15 | - Unit tests 16 | - README documentation 17 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | "@semantic-release/changelog", 7 | "@semantic-release/npm", 8 | "@semantic-release/github", 9 | [ 10 | "@semantic-release/git", 11 | { 12 | "assets": ["CHANGELOG.md", "package.json"], 13 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 14 | } 15 | ] 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /examples/multi-agent/audit-legacy.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Legacy Code Archaeologist - batch analyze files for deprecated patterns 3 | # Usage: md audit-legacy.claude.md "src/db/**/*.ts" 4 | # Note: Pass a glob pattern in quotes to use mdflow's built-in glob expansion 5 | model: sonnet 6 | print: true 7 | --- 8 | I am migrating our database. Check these files for deprecated raw SQL queries. 9 | 10 | @./{{ _1 }} 11 | 12 | If you find `db.query('SELECT...`, suggest the equivalent Prisma ORM syntax. 13 | If the file is already using Prisma, output "CLEAN". 14 | -------------------------------------------------------------------------------- /examples/multi-agent/pr-review.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Context-Aware PR Reviewer - combines diff, issue, and standards 3 | # Usage: md pr-review.claude.md 123 4 | model: sonnet 5 | print: true 6 | --- 7 | You are a senior engineer reviewing a Pull Request. 8 | 9 | ### The Code Changes 10 | !`gh pr diff {{ _1 }}` 11 | 12 | ### The Original Requirement 13 | !`gh pr view {{ _1 }} --json body -q .body` 14 | 15 | ### Our Coding Standards 16 | @./CONTRIBUTING.md 17 | 18 | Based on the requirements and our standards, provide a bulleted review. 19 | Flag any security risks immediately. 20 | -------------------------------------------------------------------------------- /.beads/.gitignore: -------------------------------------------------------------------------------- 1 | # SQLite databases 2 | *.db 3 | *.db?* 4 | *.db-journal 5 | *.db-wal 6 | *.db-shm 7 | 8 | # Daemon runtime files 9 | daemon.lock 10 | daemon.log 11 | daemon.pid 12 | bd.sock 13 | 14 | # Legacy database files 15 | db.sqlite 16 | bd.db 17 | 18 | # Merge artifacts (temporary files from 3-way merge) 19 | beads.base.jsonl 20 | beads.base.meta.json 21 | beads.left.jsonl 22 | beads.left.meta.json 23 | beads.right.jsonl 24 | beads.right.meta.json 25 | 26 | # Keep JSONL exports and config (source of truth for git) 27 | !issues.jsonl 28 | !metadata.json 29 | !config.json 30 | -------------------------------------------------------------------------------- /instructions/.beads/.gitignore: -------------------------------------------------------------------------------- 1 | # SQLite databases 2 | *.db 3 | *.db?* 4 | *.db-journal 5 | *.db-wal 6 | *.db-shm 7 | 8 | # Daemon runtime files 9 | daemon.lock 10 | daemon.log 11 | daemon.pid 12 | bd.sock 13 | 14 | # Legacy database files 15 | db.sqlite 16 | bd.db 17 | 18 | # Merge artifacts (temporary files from 3-way merge) 19 | beads.base.jsonl 20 | beads.base.meta.json 21 | beads.left.jsonl 22 | beads.left.meta.json 23 | beads.right.jsonl 24 | beads.right.meta.json 25 | 26 | # Keep JSONL exports and config (source of truth for git) 27 | !issues.jsonl 28 | !metadata.json 29 | !config.json 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[markdown]": { 3 | "editor.quickSuggestions": { 4 | "other": true, 5 | "comments": false, 6 | "strings": true 7 | } 8 | }, 9 | "workbench.colorCustomizations": { 10 | "editor.findMatchBorder": "#ffffffcc", 11 | "editor.findMatchForeground": "#ffffff", 12 | "editor.findMatchBackground": "#005500", 13 | "editor.findMatchHighlightBorder": "#ffffff", 14 | "editor.findMatchHighlightBackground": "#000000ee", 15 | "editor.findMatchHighlightForeground": "#ffffffee", 16 | "activityBar.activeBorder": "#797F1C" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | # code coverage 10 | coverage 11 | *.lcov 12 | 13 | # logs 14 | logs 15 | _.log 16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 | 18 | # dotenv environment variable files 19 | .env 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .env.local 24 | 25 | # caches 26 | .eslintcache 27 | .cache 28 | *.tsbuildinfo 29 | .mdflow 30 | 31 | # IntelliJ based IDEs 32 | .idea 33 | 34 | # Cursor 35 | .cursor/ 36 | 37 | # Finder (MacOS) folder config 38 | .DS_Store 39 | PRESENTATION.md 40 | .beads/ 41 | bundle.js 42 | 43 | # Generated bundles for AI upload 44 | mdflow-bundle.md 45 | mdflow-core-bundle.md 46 | -------------------------------------------------------------------------------- /test/fixtures/imports/types.ts: -------------------------------------------------------------------------------- 1 | // TypeScript file for symbol extraction tests 2 | 3 | export interface UserProfile { 4 | id: number; 5 | name: string; 6 | email: string; 7 | createdAt: Date; 8 | } 9 | 10 | export type Status = "active" | "inactive" | "pending"; 11 | 12 | export function formatUser(user: UserProfile): string { 13 | return `${user.name} <${user.email}>`; 14 | } 15 | 16 | export const DEFAULT_CONFIG = { 17 | apiUrl: "https://api.example.com", 18 | timeout: 5000, 19 | retries: 3, 20 | }; 21 | 22 | export class UserService { 23 | private users: UserProfile[] = []; 24 | 25 | addUser(user: UserProfile): void { 26 | this.users.push(user); 27 | } 28 | 29 | getUser(id: number): UserProfile | undefined { 30 | return this.users.find(u => u.id === id); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/adapters/claude.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Claude CLI adapter 3 | * 4 | * Print mode: --print flag for non-interactive output 5 | * Interactive mode: Remove --print flag (interactive is the default) 6 | */ 7 | 8 | import type { ToolAdapter, CommandDefaults, AgentFrontmatter } from "../types"; 9 | 10 | export const claudeAdapter: ToolAdapter = { 11 | name: "claude", 12 | 13 | getDefaults(): CommandDefaults { 14 | return { 15 | print: true, // --print flag for non-interactive mode 16 | }; 17 | }, 18 | 19 | applyInteractiveMode(frontmatter: AgentFrontmatter): AgentFrontmatter { 20 | const result = { ...frontmatter }; 21 | // Remove --print flag (interactive is default without it) 22 | delete result.print; 23 | return result; 24 | }, 25 | }; 26 | 27 | export default claudeAdapter; 28 | -------------------------------------------------------------------------------- /src/adapters/gemini.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Google Gemini CLI adapter 3 | * 4 | * Print mode: One-shot mode (no special flags needed - default behavior) 5 | * Interactive mode: Add --prompt-interactive flag 6 | */ 7 | 8 | import type { ToolAdapter, CommandDefaults, AgentFrontmatter } from "../types"; 9 | 10 | export const geminiAdapter: ToolAdapter = { 11 | name: "gemini", 12 | 13 | getDefaults(): CommandDefaults { 14 | // Gemini defaults to one-shot mode (no special flags needed) 15 | return {}; 16 | }, 17 | 18 | applyInteractiveMode(frontmatter: AgentFrontmatter): AgentFrontmatter { 19 | const result = { ...frontmatter }; 20 | // Add --prompt-interactive flag for interactive mode 21 | result.$1 = "prompt-interactive"; 22 | return result; 23 | }, 24 | }; 25 | 26 | export default geminiAdapter; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/adapters/opencode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * OpenCode CLI adapter 3 | * 4 | * Print mode: Use 'run' subcommand for non-interactive execution 5 | * Interactive mode: Remove subcommand (TUI is the default) 6 | */ 7 | 8 | import type { ToolAdapter, CommandDefaults, AgentFrontmatter } from "../types"; 9 | 10 | export const opencodeAdapter: ToolAdapter = { 11 | name: "opencode", 12 | 13 | getDefaults(): CommandDefaults { 14 | return { 15 | _subcommand: "run", // Use 'run' subcommand for non-interactive mode 16 | }; 17 | }, 18 | 19 | applyInteractiveMode(frontmatter: AgentFrontmatter): AgentFrontmatter { 20 | const result = { ...frontmatter }; 21 | // Remove _subcommand (TUI is default without run subcommand) 22 | delete result._subcommand; 23 | return result; 24 | }, 25 | }; 26 | 27 | export default opencodeAdapter; 28 | -------------------------------------------------------------------------------- /src/adapters/codex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * OpenAI Codex CLI adapter 3 | * 4 | * Print mode: Use 'exec' subcommand for non-interactive execution 5 | * Interactive mode: Remove subcommand (interactive is the default) 6 | */ 7 | 8 | import type { ToolAdapter, CommandDefaults, AgentFrontmatter } from "../types"; 9 | 10 | export const codexAdapter: ToolAdapter = { 11 | name: "codex", 12 | 13 | getDefaults(): CommandDefaults { 14 | return { 15 | _subcommand: "exec", // Use 'exec' subcommand for non-interactive mode 16 | }; 17 | }, 18 | 19 | applyInteractiveMode(frontmatter: AgentFrontmatter): AgentFrontmatter { 20 | const result = { ...frontmatter }; 21 | // Remove _subcommand (interactive is default without exec subcommand) 22 | delete result._subcommand; 23 | return result; 24 | }, 25 | }; 26 | 27 | export default codexAdapter; 28 | -------------------------------------------------------------------------------- /src/adapters/droid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Factory Droid CLI adapter 3 | * 4 | * Print mode: Use 'exec' subcommand for non-interactive execution 5 | * Interactive mode: Remove subcommand (interactive is the default) 6 | */ 7 | 8 | import type { ToolAdapter, CommandDefaults, AgentFrontmatter } from "../types"; 9 | 10 | export const droidAdapter: ToolAdapter = { 11 | name: "droid", 12 | 13 | getDefaults(): CommandDefaults { 14 | return { 15 | _subcommand: "exec", // Use 'exec' subcommand for non-interactive mode 16 | }; 17 | }, 18 | 19 | applyInteractiveMode(frontmatter: AgentFrontmatter): AgentFrontmatter { 20 | const result = { ...frontmatter }; 21 | // Remove _subcommand (interactive is default without exec subcommand) 22 | delete result._subcommand; 23 | return result; 24 | }, 25 | }; 26 | 27 | export default droidAdapter; 28 | -------------------------------------------------------------------------------- /src/adapters/copilot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub Copilot CLI adapter 3 | * 4 | * Print mode: Map body to --prompt flag, silent mode for clean output 5 | * Interactive mode: Map body to --interactive flag instead 6 | */ 7 | 8 | import type { ToolAdapter, CommandDefaults, AgentFrontmatter } from "../types"; 9 | 10 | export const copilotAdapter: ToolAdapter = { 11 | name: "copilot", 12 | 13 | getDefaults(): CommandDefaults { 14 | return { 15 | $1: "prompt", // Map body to --prompt for copilot (print mode) 16 | silent: true, // Output only the agent response (no stats) 17 | }; 18 | }, 19 | 20 | applyInteractiveMode(frontmatter: AgentFrontmatter): AgentFrontmatter { 21 | const result = { ...frontmatter }; 22 | // Change from --prompt to --interactive 23 | result.$1 = "interactive"; 24 | return result; 25 | }, 26 | }; 27 | 28 | export default copilotAdapter; 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | issues: write 11 | id-token: write 12 | 13 | jobs: 14 | release: 15 | name: Release 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 22 27 | 28 | - name: Setup Bun 29 | uses: oven-sh/setup-bun@v2 30 | 31 | - name: Install dependencies 32 | run: bun install 33 | 34 | - name: Run tests 35 | run: bun test 36 | 37 | - name: Release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | run: bunx semantic-release 42 | -------------------------------------------------------------------------------- /test/fixtures/complex-combined.claude.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: opus 3 | max-tokens: 4096 4 | args: 5 | - feature_name 6 | --- 7 | 8 | # Feature Implementation: {{ feature_name }} 9 | 10 | ## Context 11 | 12 | This document describes the implementation of the {{ feature_name }} feature. 13 | 14 | ### Type Definitions 15 | 16 | @./imports/types.ts#UserProfile 17 | 18 | ### Configuration 19 | 20 | @./imports/types.ts#DEFAULT_CONFIG 21 | 22 | ### Helper Content 23 | 24 | @./imports/helper.md 25 | 26 | ## Requirements 27 | 28 | {% if strict_mode %} 29 | Running in strict mode - all validations will be enforced. 30 | {% else %} 31 | Running in lenient mode - some validations may be skipped. 32 | {% endif %} 33 | 34 | ## Reference Lines 35 | 36 | Here are lines 4-6 from the reference doc: 37 | 38 | @./imports/lines.txt:4-6 39 | 40 | ## Instructions 41 | 42 | Please implement the {{ feature_name }} feature following these guidelines. 43 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | /** 3 | * Build script for mdflow 4 | * Creates a minified bundle with all dependencies externalized 5 | */ 6 | 7 | import { readFileSync } from "fs"; 8 | import { join } from "path"; 9 | 10 | const pkgPath = join(import.meta.dir, "..", "package.json"); 11 | const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); 12 | 13 | // Get all dependencies to externalize 14 | const external = [ 15 | ...Object.keys(pkg.dependencies || {}), 16 | ...Object.keys(pkg.devDependencies || {}), 17 | ...Object.keys(pkg.peerDependencies || {}), 18 | ]; 19 | 20 | const result = await Bun.build({ 21 | entrypoints: ["./src/index.ts"], 22 | outdir: "./dist", 23 | target: "bun", 24 | format: "esm", 25 | minify: true, 26 | sourcemap: "none", 27 | external, 28 | naming: "md.js", 29 | }); 30 | 31 | if (!result.success) { 32 | console.error("Build failed:"); 33 | for (const log of result.logs) { 34 | console.error(log); 35 | } 36 | process.exit(1); 37 | } 38 | 39 | console.log("Build successful: dist/md.js"); 40 | -------------------------------------------------------------------------------- /src/spinner.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"; 2 | import { startSpinner, stopSpinner, isSpinnerRunning } from "./spinner"; 3 | 4 | describe("spinner", () => { 5 | beforeEach(() => { 6 | // Ensure spinner is stopped before each test 7 | stopSpinner(); 8 | }); 9 | 10 | afterEach(() => { 11 | // Clean up after each test 12 | stopSpinner(); 13 | }); 14 | 15 | it("should not start on non-TTY", () => { 16 | // The test runner is not a TTY, so spinner should not start 17 | startSpinner("Test message"); 18 | expect(isSpinnerRunning()).toBe(false); 19 | }); 20 | 21 | it("should report not running when stopped", () => { 22 | expect(isSpinnerRunning()).toBe(false); 23 | }); 24 | 25 | it("should handle multiple stopSpinner calls gracefully", () => { 26 | stopSpinner(); 27 | stopSpinner(); 28 | stopSpinner(); 29 | expect(isSpinnerRunning()).toBe(false); 30 | }); 31 | 32 | it("should export all required functions", () => { 33 | expect(typeof startSpinner).toBe("function"); 34 | expect(typeof stopSpinner).toBe("function"); 35 | expect(typeof isSpinnerRunning).toBe("function"); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /instructions/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: claude-haiku-4.5 3 | silent: true 4 | allow-tool: write 5 | --- 6 | 7 | # Copilot Prompt Agents 8 | 9 | Drop `.md` files here with YAML frontmatter to create reusable copilot prompts. 10 | 11 | ## Usage 12 | 13 | Just type the filename in your terminal: 14 | 15 | ```bash 16 | CHECK_ACTIONS.md 17 | DEMO.md 18 | ``` 19 | 20 | ## Frontmatter Options 21 | 22 | | Field | Type | Description | 23 | |-------|------|-------------| 24 | | `model` | enum | AI model (claude-haiku-4.5, claude-opus-4.5, gpt-5, etc.) | 25 | | `agent` | string | Custom agent name | 26 | | `silent` | bool | Only output response, no stats | 27 | | `interactive` | bool | Start interactive mode | 28 | | `allow-all-tools` | bool | Auto-approve all tools | 29 | | `allow-all-paths` | bool | Allow access to any file path | 30 | | `allow-tool` | string | Allow specific tools | 31 | | `deny-tool` | string | Deny specific tools | 32 | | `add-dir` | string | Additional allowed directory | 33 | 34 | ## Example 35 | 36 | ```markdown 37 | --- 38 | pre: gh run list --limit 5 39 | model: claude-haiku-4.5 40 | silent: true 41 | --- 42 | 43 | Analyze the CI output above and summarize any failures. 44 | ``` 45 | 46 | 47 | Fix the current README.md based on the codebase 48 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | /** 3 | * Entry point for mdflow CLI 4 | * 5 | * This is a minimal entry point that: 6 | * 1. Initializes ProcessManager for centralized lifecycle management 7 | * 2. Sets up EPIPE handlers for graceful pipe handling 8 | * 3. Creates a CliRunner with the real system environment 9 | * 4. Runs the CLI and exits with the appropriate code 10 | * 11 | * All orchestration logic is in CliRunner for testability. 12 | */ 13 | 14 | import { CliRunner } from "./cli-runner"; 15 | import { BunSystemEnvironment } from "./system-environment"; 16 | import { getProcessManager } from "./process-manager"; 17 | 18 | async function main() { 19 | // Initialize ProcessManager early for centralized signal handling 20 | // This ensures cursor restoration and process cleanup on SIGINT/SIGTERM 21 | const pm = getProcessManager(); 22 | pm.initialize(); 23 | 24 | // Handle EPIPE gracefully when downstream closes the pipe early 25 | // (e.g., `md task.md | head -n 5`) 26 | process.stdout.on("error", (err: NodeJS.ErrnoException) => { 27 | if (err.code === "EPIPE") { 28 | pm.restoreTerminal(); // Ensure cursor is visible 29 | process.exit(0); 30 | } 31 | throw err; 32 | }); 33 | 34 | process.stderr.on("error", (err: NodeJS.ErrnoException) => { 35 | if (err.code === "EPIPE") { 36 | pm.restoreTerminal(); // Ensure cursor is visible 37 | process.exit(0); 38 | } 39 | throw err; 40 | }); 41 | 42 | // Create the runner with the real system environment 43 | const runner = new CliRunner({ 44 | env: new BunSystemEnvironment(), 45 | }); 46 | 47 | // Run the CLI and exit with the result code 48 | const result = await runner.run(process.argv); 49 | process.exit(result.exitCode); 50 | } 51 | 52 | main(); 53 | -------------------------------------------------------------------------------- /src/spinner.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple spinner for CLI feedback 3 | * Shows an animated spinner with a message while work is in progress 4 | * 5 | * Integrates with ProcessManager for proper cursor restoration on SIGINT/SIGTERM 6 | */ 7 | 8 | import { getProcessManager } from "./process-manager"; 9 | 10 | const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; 11 | 12 | let currentFrame = 0; 13 | let interval: ReturnType | null = null; 14 | let currentMessage = ''; 15 | 16 | function render() { 17 | const frame = SPINNER_FRAMES[currentFrame % SPINNER_FRAMES.length]; 18 | // Clear line, write spinner + message 19 | process.stderr.write(`\r\x1B[K${frame} ${currentMessage}`); 20 | currentFrame++; 21 | } 22 | 23 | /** 24 | * Start the spinner with a message 25 | * Only shows on TTY terminals 26 | */ 27 | export function startSpinner(message: string): void { 28 | if (!process.stderr.isTTY) return; 29 | if (interval) return; // Already running 30 | 31 | currentMessage = message; 32 | currentFrame = 0; 33 | 34 | // Hide cursor and notify ProcessManager 35 | process.stderr.write('\x1B[?25l'); 36 | getProcessManager().setCursorHidden(true); 37 | 38 | render(); 39 | interval = setInterval(render, 80); 40 | } 41 | 42 | /** 43 | * Stop the spinner and clear the line 44 | */ 45 | export function stopSpinner(): void { 46 | if (!interval) return; 47 | 48 | clearInterval(interval); 49 | interval = null; 50 | 51 | // Clear line and show cursor 52 | process.stderr.write('\r\x1B[K'); 53 | process.stderr.write('\x1B[?25h'); 54 | 55 | // Notify ProcessManager that cursor is restored 56 | getProcessManager().setCursorHidden(false); 57 | } 58 | 59 | /** 60 | * Check if spinner is currently running 61 | */ 62 | export function isSpinnerRunning(): boolean { 63 | return interval !== null; 64 | } 65 | -------------------------------------------------------------------------------- /instructions/SETUP_NPM_PUBLISHING.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: claude-sonnet-4 3 | silent: true 4 | copilot: 5 | allowed-tools: 6 | - shell(mkdir:*) 7 | - shell(bun:*) 8 | - shell(git:*) 9 | - shell(npm:*) 10 | - shell(open:*) 11 | - write 12 | interactive: true 13 | --- 14 | 15 | Set up npm Trusted Publishing with GitHub Actions for this project. 16 | 17 | ## Steps to perform: 18 | 19 | 0. **Prompt for npm package name**: 20 | - Ask the user: "What is your npm package name?" 21 | - Check if the package exists on npm using `npm view {package-name}` 22 | - If it doesn't exist, ask if they want to create it (they'll need to publish manually first or reserve the name) 23 | - Ask: "Have you already configured Trusted Publisher for this package on npm? (yes/no)" 24 | - If no, open `https://www.npmjs.com/package/{package-name}/access` in the browser and instruct them to: 25 | - Scroll to Trusted Publishers → Connect a new publisher → GitHub 26 | - Repository owner: (from git remote) 27 | - Repository name: (from git remote) 28 | - Workflow filename: release.yml 29 | - Environment: (leave empty) 30 | - Wait for user confirmation before proceeding 31 | 32 | 1. **Create `.github/workflows/release.yml`** with: 33 | - Trigger on push to main 34 | - Permissions: `contents: write`, `issues: write`, `id-token: write` 35 | - Use `actions/checkout@v4`, `oven-sh/setup-bun@v2` 36 | - Run `bun install` and `bun run build` (if build script exists) 37 | - Run `bunx semantic-release` 38 | 39 | 2. **Create `.releaserc.json`** with plugins: 40 | - @semantic-release/commit-analyzer 41 | - @semantic-release/release-notes-generator 42 | - @semantic-release/changelog 43 | - @semantic-release/npm 44 | - @semantic-release/github 45 | - @semantic-release/git (commit CHANGELOG.md and package.json) 46 | 47 | 3. **Install dependencies**: -------------------------------------------------------------------------------- /src/imports-injector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Phase 3: Pure Injector 3 | * 4 | * Stitches resolved content back into the original template. 5 | * This is a pure function with no I/O. 6 | */ 7 | 8 | import type { ResolvedImport } from './imports-types'; 9 | 10 | /** 11 | * Inject resolved imports back into the original content 12 | * 13 | * This is a pure function that replaces import markers with their resolved content. 14 | * It processes imports in reverse order to preserve string indices. 15 | * 16 | * @param original - The original content with import markers 17 | * @param resolved - Array of resolved imports with their content 18 | * @returns The content with all imports replaced by their resolved content 19 | */ 20 | export function injectImports(original: string, resolved: ResolvedImport[]): string { 21 | if (resolved.length === 0) { 22 | return original; 23 | } 24 | 25 | let result = original; 26 | 27 | // Sort by index in descending order to process from end to start 28 | // This preserves indices as we make replacements 29 | const sortedResolved = [...resolved].sort((a, b) => b.action.index - a.action.index); 30 | 31 | for (const { action, content } of sortedResolved) { 32 | const before = result.slice(0, action.index); 33 | const after = result.slice(action.index + action.original.length); 34 | result = before + content + after; 35 | } 36 | 37 | return result; 38 | } 39 | 40 | /** 41 | * Create a ResolvedImport from an action and content 42 | * 43 | * Utility function to create resolved imports for testing or manual construction. 44 | * 45 | * @param action - The import action 46 | * @param content - The resolved content 47 | * @returns A ResolvedImport object 48 | */ 49 | export function createResolvedImport( 50 | action: ResolvedImport['action'], 51 | content: string 52 | ): ResolvedImport { 53 | return { action, content }; 54 | } 55 | -------------------------------------------------------------------------------- /src/limits.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Input size limits for OOM protection 3 | * 4 | * Prevents memory exhaustion when users pipe large files or import massive content. 5 | * A 10MB limit provides reasonable headroom for most use cases while preventing 6 | * catastrophic memory usage. 7 | */ 8 | 9 | /** Maximum input size in bytes (10MB) */ 10 | export const MAX_INPUT_SIZE = 10 * 1024 * 1024; 11 | 12 | /** Human-readable size for error messages */ 13 | export const MAX_INPUT_SIZE_HUMAN = "10MB"; 14 | 15 | /** 16 | * Error thrown when stdin input exceeds the size limit 17 | */ 18 | export class StdinSizeLimitError extends Error { 19 | constructor(bytesRead: number) { 20 | super( 21 | `Input exceeds ${MAX_INPUT_SIZE_HUMAN} limit (read ${formatBytes(bytesRead)} so far). ` + 22 | `Use a file path argument instead of piping large content.` 23 | ); 24 | this.name = "StdinSizeLimitError"; 25 | } 26 | } 27 | 28 | /** 29 | * Error thrown when a file import exceeds the size limit 30 | */ 31 | export class FileSizeLimitError extends Error { 32 | constructor(filePath: string, fileSize: number) { 33 | super( 34 | `File "${filePath}" exceeds ${MAX_INPUT_SIZE_HUMAN} limit (${formatBytes(fileSize)}). ` + 35 | `Consider using line ranges (@./file.ts:1-100) or symbol extraction (@./file.ts#FunctionName) ` + 36 | `to import only the relevant portion.` 37 | ); 38 | this.name = "FileSizeLimitError"; 39 | } 40 | } 41 | 42 | /** 43 | * Format bytes as human-readable string 44 | */ 45 | export function formatBytes(bytes: number): string { 46 | if (bytes < 1024) return `${bytes} bytes`; 47 | if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; 48 | return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; 49 | } 50 | 51 | /** 52 | * Check if a size exceeds the input limit 53 | */ 54 | export function exceedsLimit(bytes: number): boolean { 55 | return bytes > MAX_INPUT_SIZE; 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mdflow", 3 | "version": "2.35.0", 4 | "module": "src/index.ts", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/johnlindquist/mdflow.git" 9 | }, 10 | "bin": { 11 | "mdflow": "./src/index.ts", 12 | "md": "./src/index.ts", 13 | "md.claude": "./src/index.ts", 14 | "md.codex": "./src/index.ts", 15 | "md.gemini": "./src/index.ts", 16 | "md.copilot": "./src/index.ts", 17 | "md.droid": "./src/index.ts", 18 | "md.opencode": "./src/index.ts", 19 | "md.i.claude": "./src/index.ts", 20 | "md.i.codex": "./src/index.ts", 21 | "md.i.gemini": "./src/index.ts", 22 | "md.i.copilot": "./src/index.ts", 23 | "md.i.droid": "./src/index.ts", 24 | "md.i.opencode": "./src/index.ts" 25 | }, 26 | "scripts": { 27 | "test": "bun test --bail=1", 28 | "md": "bun run src/index.ts", 29 | "bundle": "bun run scripts/bundle.ts -o", 30 | "bundle:core": "bun run scripts/bundle.ts --core -o", 31 | "bundle:stdout": "bun run scripts/bundle.ts" 32 | }, 33 | "devDependencies": { 34 | "@semantic-release/changelog": "^6.0.3", 35 | "@semantic-release/commit-analyzer": "^13.0.1", 36 | "@semantic-release/git": "^10.0.1", 37 | "@semantic-release/github": "^12.0.2", 38 | "@semantic-release/npm": "^13.1.2", 39 | "@semantic-release/release-notes-generator": "^14.1.0", 40 | "@types/bun": "latest", 41 | "@types/js-yaml": "^4.0.9", 42 | "@types/mdast": "^4.0.4", 43 | "fast-check": "^4.4.0", 44 | "semantic-release": "^25.0.2" 45 | }, 46 | "peerDependencies": { 47 | "typescript": "^5" 48 | }, 49 | "dependencies": { 50 | "@inquirer/core": "^10.1.11", 51 | "@inquirer/prompts": "^8.0.2", 52 | "gpt-tokenizer": "^3.4.0", 53 | "ignore": "^7.0.5", 54 | "js-yaml": "^4.1.1", 55 | "liquidjs": "^10.24.0", 56 | "marked": "^17.0.1", 57 | "marked-terminal": "^7.3.0", 58 | "pino": "^10.1.0", 59 | "pino-pretty": "^13.1.3", 60 | "remark-parse": "^11.0.0", 61 | "unified": "^11.0.5", 62 | "unist-util-visit": "^5.0.0", 63 | "zod": "^4.1.13" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.beads/config.yaml: -------------------------------------------------------------------------------- 1 | # Beads Configuration File 2 | # This file configures default behavior for all bd commands in this repository 3 | # All settings can also be set via environment variables (BD_* prefix) 4 | # or overridden with command-line flags 5 | 6 | # Issue prefix for this repository (used by bd init) 7 | # If not set, bd init will auto-detect from directory name 8 | # Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. 9 | # issue-prefix: "" 10 | 11 | # Use no-db mode: load from JSONL, no SQLite, write back after each command 12 | # When true, bd will use .beads/issues.jsonl as the source of truth 13 | # instead of SQLite database 14 | # no-db: false 15 | 16 | # Disable daemon for RPC communication (forces direct database access) 17 | # no-daemon: false 18 | 19 | # Disable auto-flush of database to JSONL after mutations 20 | # no-auto-flush: false 21 | 22 | # Disable auto-import from JSONL when it's newer than database 23 | # no-auto-import: false 24 | 25 | # Enable JSON output by default 26 | # json: false 27 | 28 | # Default actor for audit trails (overridden by BD_ACTOR or --actor) 29 | # actor: "" 30 | 31 | # Path to database (overridden by BEADS_DB or --db) 32 | # db: "" 33 | 34 | # Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) 35 | # auto-start-daemon: true 36 | 37 | # Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) 38 | # flush-debounce: "5s" 39 | 40 | # Multi-repo configuration (experimental - bd-307) 41 | # Allows hydrating from multiple repositories and routing writes to the correct JSONL 42 | # repos: 43 | # primary: "." # Primary repo (where this database lives) 44 | # additional: # Additional repos to hydrate from (read-only) 45 | # - ~/beads-planning # Personal planning repo 46 | # - ~/work-planning # Work planning repo 47 | 48 | # Integration settings (access with 'bd config get/set') 49 | # These are stored in the database, not in this file: 50 | # - jira.url 51 | # - jira.project 52 | # - linear.url 53 | # - linear.api-key 54 | # - github.org 55 | # - github.repo 56 | # - sync.branch - Git branch for beads commits (use BEADS_SYNC_BRANCH env var or bd config set) 57 | -------------------------------------------------------------------------------- /instructions/.beads/config.yaml: -------------------------------------------------------------------------------- 1 | # Beads Configuration File 2 | # This file configures default behavior for all bd commands in this repository 3 | # All settings can also be set via environment variables (BD_* prefix) 4 | # or overridden with command-line flags 5 | 6 | # Issue prefix for this repository (used by bd init) 7 | # If not set, bd init will auto-detect from directory name 8 | # Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. 9 | # issue-prefix: "" 10 | 11 | # Use no-db mode: load from JSONL, no SQLite, write back after each command 12 | # When true, bd will use .beads/issues.jsonl as the source of truth 13 | # instead of SQLite database 14 | # no-db: false 15 | 16 | # Disable daemon for RPC communication (forces direct database access) 17 | # no-daemon: false 18 | 19 | # Disable auto-flush of database to JSONL after mutations 20 | # no-auto-flush: false 21 | 22 | # Disable auto-import from JSONL when it's newer than database 23 | # no-auto-import: false 24 | 25 | # Enable JSON output by default 26 | # json: false 27 | 28 | # Default actor for audit trails (overridden by BD_ACTOR or --actor) 29 | # actor: "" 30 | 31 | # Path to database (overridden by BEADS_DB or --db) 32 | # db: "" 33 | 34 | # Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) 35 | # auto-start-daemon: true 36 | 37 | # Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) 38 | # flush-debounce: "5s" 39 | 40 | # Multi-repo configuration (experimental - bd-307) 41 | # Allows hydrating from multiple repositories and routing writes to the correct JSONL 42 | # repos: 43 | # primary: "." # Primary repo (where this database lives) 44 | # additional: # Additional repos to hydrate from (read-only) 45 | # - ~/beads-planning # Personal planning repo 46 | # - ~/work-planning # Work planning repo 47 | 48 | # Integration settings (access with 'bd config get/set') 49 | # These are stored in the database, not in this file: 50 | # - jira.url 51 | # - jira.project 52 | # - linear.url 53 | # - linear.api-key 54 | # - github.org 55 | # - github.repo 56 | # - sync.branch - Git branch for beads commits (use BEADS_SYNC_BRANCH env var or bd config set) 57 | -------------------------------------------------------------------------------- /instructions/CREATE_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | --- 2 | model: claude-sonnet-4 3 | silent: true 4 | allow-tool: 5 | - write 6 | interactive: true 7 | --- 8 | 9 | Create a new copilot instructions file based on a user scenario. 10 | 11 | ## Steps to perform: 12 | 13 | 1. **Ask the user**: "Describe the task or workflow you want to automate (e.g., 'set up npm publishing', 'deploy to AWS', 'create a new React component'):" 14 | 15 | 2. **Ask for the filename**: "What should this instructions file be called? (e.g., SETUP_NPM_PUBLISHING.md, DEPLOY_AWS.md):" 16 | 17 | 3. **Gather requirements**: 18 | - "What tools/commands will this task need? (e.g., git, npm, bun, aws, docker)" 19 | - "Should this be interactive (pause for user input)? (yes/no)" 20 | - "What model should be used? (claude-haiku-4.5 for simple, claude-sonnet-4 for complex)" 21 | 22 | 4. **Create the instructions file** with: 23 | - YAML frontmatter containing: 24 | - `model`: based on complexity 25 | - `silent: true` 26 | - `allow-tool`: list of shell commands needed (e.g., `shell(git:*)`, `shell(npm:*)`) 27 | - `interactive: true` if user input is needed 28 | - Clear step-by-step instructions for the AI to follow 29 | - Any user prompts needed (step 0 pattern for gathering info) 30 | - Verification steps where appropriate 31 | 32 | 5. **Save the file** to `/Users/johnlindquist/agents/instructions/{filename}` 33 | 34 | ## Example structure: 35 | 36 | ```markdown 37 | --- 38 | model: claude-sonnet-4 39 | silent: true 40 | allow-tool: 41 | - shell(command:*) 42 | - write 43 | interactive: true 44 | --- 45 | 46 | Description of what this instructions file does. 47 | 48 | ## Steps to perform: 49 | 50 | 0. **Gather information from user**: 51 | - Ask relevant questions 52 | - Validate inputs 53 | - Wait for confirmation before proceeding 54 | 55 | 1. **First major step**: 56 | - Sub-step details 57 | - Commands to run 58 | 59 | 2. **Second major step**: 60 | - Sub-step details 61 | 62 | 3. **Verification**: 63 | - How to confirm success 64 | ``` 65 | 66 | ## Reference files for patterns: 67 | - SETUP_NPM_PUBLISHING.md - Interactive setup with user prompts and browser opening 68 | - CHECK_ACTIONS.md - Interactive CI status checking 69 | -------------------------------------------------------------------------------- /src/epipe.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from "bun:test"; 2 | import { spawn } from "bun"; 3 | import { join } from "path"; 4 | import { tmpdir } from "os"; 5 | import { writeFile, unlink } from "fs/promises"; 6 | 7 | /** 8 | * Tests for EPIPE (broken pipe) handling. 9 | * When downstream closes the pipe early (e.g., `md task.md | head -n 5`), 10 | * md should exit gracefully with code 0 instead of crashing with EPIPE. 11 | */ 12 | 13 | describe("EPIPE handling", () => { 14 | test("exits gracefully when stdout pipe is closed early", async () => { 15 | // Create a temporary markdown file that outputs a lot of text 16 | const tempFile = join(tmpdir(), `test-epipe-${Date.now()}.echo.md`); 17 | await writeFile(tempFile, `--- 18 | --- 19 | ${"A".repeat(10000)} 20 | `); 21 | 22 | try { 23 | // Run md and pipe to head -n 1, which will close the pipe early 24 | const proc = spawn({ 25 | cmd: ["bash", "-c", `bun run ${join(process.cwd(), "src/index.ts")} ${tempFile} | head -n 1`], 26 | stdout: "pipe", 27 | stderr: "pipe", 28 | env: { ...process.env, MA_COMMAND: "echo" }, 29 | }); 30 | 31 | const exitCode = await proc.exited; 32 | 33 | // The process should exit with 0 (from EPIPE handler) or the normal exit code 34 | // The key is that it shouldn't crash with an unhandled error 35 | expect([0, 141]).toContain(exitCode); // 141 = 128 + 13 (SIGPIPE) 36 | } finally { 37 | await unlink(tempFile).catch(() => {}); 38 | } 39 | }); 40 | 41 | test("process.stdout error handler is attached", async () => { 42 | // This test verifies the error handler structure by checking the source 43 | const indexPath = join(process.cwd(), "src/index.ts"); 44 | const content = await Bun.file(indexPath).text(); 45 | 46 | // Verify EPIPE handling code exists 47 | expect(content).toContain('process.stdout.on("error"'); 48 | expect(content).toContain('err.code === "EPIPE"'); 49 | expect(content).toContain("process.exit(0)"); 50 | }); 51 | 52 | test("process.stderr error handler is attached", async () => { 53 | const indexPath = join(process.cwd(), "src/index.ts"); 54 | const content = await Bun.file(indexPath).text(); 55 | 56 | // Verify stderr EPIPE handling code exists 57 | expect(content).toContain('process.stderr.on("error"'); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /.beads/README.md: -------------------------------------------------------------------------------- 1 | # Beads - AI-Native Issue Tracking 2 | 3 | Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. 4 | 5 | ## What is Beads? 6 | 7 | Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. 8 | 9 | **Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) 10 | 11 | ## Quick Start 12 | 13 | ### Essential Commands 14 | 15 | ```bash 16 | # Create new issues 17 | bd create "Add user authentication" 18 | 19 | # View all issues 20 | bd list 21 | 22 | # View issue details 23 | bd show 24 | 25 | # Update issue status 26 | bd update --status in-progress 27 | bd update --status done 28 | 29 | # Sync with git remote 30 | bd sync 31 | ``` 32 | 33 | ### Working with Issues 34 | 35 | Issues in Beads are: 36 | - **Git-native**: Stored in `.beads/issues.jsonl` and synced like code 37 | - **AI-friendly**: CLI-first design works perfectly with AI coding agents 38 | - **Branch-aware**: Issues can follow your branch workflow 39 | - **Always in sync**: Auto-syncs with your commits 40 | 41 | ## Why Beads? 42 | 43 | ✨ **AI-Native Design** 44 | - Built specifically for AI-assisted development workflows 45 | - CLI-first interface works seamlessly with AI coding agents 46 | - No context switching to web UIs 47 | 48 | 🚀 **Developer Focused** 49 | - Issues live in your repo, right next to your code 50 | - Works offline, syncs when you push 51 | - Fast, lightweight, and stays out of your way 52 | 53 | 🔧 **Git Integration** 54 | - Automatic sync with git commits 55 | - Branch-aware issue tracking 56 | - Intelligent JSONL merge resolution 57 | 58 | ## Get Started with Beads 59 | 60 | Try Beads in your own projects: 61 | 62 | ```bash 63 | # Install Beads 64 | curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash 65 | 66 | # Initialize in your repo 67 | bd init 68 | 69 | # Create your first issue 70 | bd create "Try out Beads" 71 | ``` 72 | 73 | ## Learn More 74 | 75 | - **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) 76 | - **Quick Start Guide**: Run `bd quickstart` 77 | - **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) 78 | 79 | --- 80 | 81 | *Beads: Issue tracking that moves at the speed of thought* ⚡ 82 | -------------------------------------------------------------------------------- /instructions/.beads/README.md: -------------------------------------------------------------------------------- 1 | # Beads - AI-Native Issue Tracking 2 | 3 | Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. 4 | 5 | ## What is Beads? 6 | 7 | Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. 8 | 9 | **Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) 10 | 11 | ## Quick Start 12 | 13 | ### Essential Commands 14 | 15 | ```bash 16 | # Create new issues 17 | bd create "Add user authentication" 18 | 19 | # View all issues 20 | bd list 21 | 22 | # View issue details 23 | bd show 24 | 25 | # Update issue status 26 | bd update --status in-progress 27 | bd update --status done 28 | 29 | # Sync with git remote 30 | bd sync 31 | ``` 32 | 33 | ### Working with Issues 34 | 35 | Issues in Beads are: 36 | - **Git-native**: Stored in `.beads/issues.jsonl` and synced like code 37 | - **AI-friendly**: CLI-first design works perfectly with AI coding agents 38 | - **Branch-aware**: Issues can follow your branch workflow 39 | - **Always in sync**: Auto-syncs with your commits 40 | 41 | ## Why Beads? 42 | 43 | ✨ **AI-Native Design** 44 | - Built specifically for AI-assisted development workflows 45 | - CLI-first interface works seamlessly with AI coding agents 46 | - No context switching to web UIs 47 | 48 | 🚀 **Developer Focused** 49 | - Issues live in your repo, right next to your code 50 | - Works offline, syncs when you push 51 | - Fast, lightweight, and stays out of your way 52 | 53 | 🔧 **Git Integration** 54 | - Automatic sync with git commits 55 | - Branch-aware issue tracking 56 | - Intelligent JSONL merge resolution 57 | 58 | ## Get Started with Beads 59 | 60 | Try Beads in your own projects: 61 | 62 | ```bash 63 | # Install Beads 64 | curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash 65 | 66 | # Initialize in your repo 67 | bd init 68 | 69 | # Create your first issue 70 | bd create "Try out Beads" 71 | ``` 72 | 73 | ## Learn More 74 | 75 | - **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) 76 | - **Quick Start Guide**: Run `bd quickstart` 77 | - **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) 78 | 79 | --- 80 | 81 | *Beads: Issue tracking that moves at the speed of thought* ⚡ 82 | -------------------------------------------------------------------------------- /src/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, afterAll, beforeAll } from "bun:test"; 2 | import { getLogDir, getAgentLogPath, listLogDirs, initLogger } from "./logger"; 3 | import { homedir } from "os"; 4 | import { join } from "path"; 5 | import { readFileSync, unlinkSync, existsSync, rmdirSync } from "fs"; 6 | 7 | describe("logger", () => { 8 | test("getLogDir returns correct path", () => { 9 | expect(getLogDir()).toBe(join(homedir(), ".mdflow", "logs")); 10 | }); 11 | 12 | test("getAgentLogPath generates path based on agent name", () => { 13 | const path = getAgentLogPath("task.claude.md"); 14 | expect(path).toBe(join(homedir(), ".mdflow", "logs", "task-claude", "debug.log")); 15 | }); 16 | 17 | test("getAgentLogPath handles simple filenames", () => { 18 | const path = getAgentLogPath("review.md"); 19 | expect(path).toBe(join(homedir(), ".mdflow", "logs", "review", "debug.log")); 20 | }); 21 | 22 | test("listLogDirs returns array", () => { 23 | const dirs = listLogDirs(); 24 | expect(Array.isArray(dirs)).toBe(true); 25 | }); 26 | }); 27 | 28 | describe("logger secret redaction", () => { 29 | const testAgentFile = "secret-redaction-test.claude.md"; 30 | const logPath = getAgentLogPath(testAgentFile); 31 | const logDir = join(homedir(), ".mdflow", "logs", "secret-redaction-test-claude"); 32 | 33 | afterAll(() => { 34 | // Clean up test log file 35 | try { 36 | if (existsSync(logPath)) { 37 | unlinkSync(logPath); 38 | } 39 | if (existsSync(logDir)) { 40 | rmdirSync(logDir); 41 | } 42 | } catch { 43 | // Ignore cleanup errors 44 | } 45 | }); 46 | 47 | test("redacts sensitive values in log objects", async () => { 48 | const logger = initLogger(testAgentFile); 49 | 50 | // Log an object with sensitive keys 51 | logger.info({ 52 | api_key: "sk-secret123", 53 | model: "opus", 54 | token: "ghp_mytoken", 55 | }, "Test log with secrets"); 56 | 57 | // Force flush 58 | logger.flush(); 59 | 60 | // Give pino time to write (it's async) 61 | await new Promise((resolve) => setTimeout(resolve, 100)); 62 | 63 | // Read the log file 64 | const logContent = readFileSync(logPath, "utf-8"); 65 | 66 | // Verify sensitive values are redacted 67 | expect(logContent).toContain("[REDACTED]"); 68 | expect(logContent).not.toContain("sk-secret123"); 69 | expect(logContent).not.toContain("ghp_mytoken"); 70 | 71 | // Verify non-sensitive values are preserved 72 | expect(logContent).toContain("opus"); 73 | expect(logContent).toContain("Test log with secrets"); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | import yaml from "js-yaml"; 2 | import type { AgentFrontmatter, ParsedMarkdown } from "./types"; 3 | import { validateFrontmatter } from "./schema"; 4 | 5 | /** 6 | * Strip shebang line from content if present 7 | * Allows markdown files to be executable with #!/usr/bin/env md 8 | */ 9 | export function stripShebang(content: string): string { 10 | const lines = content.split("\n"); 11 | if (lines[0]?.startsWith("#!")) { 12 | return lines.slice(1).join("\n"); 13 | } 14 | return content; 15 | } 16 | 17 | /** 18 | * Raw parsed frontmatter result (before validation) 19 | */ 20 | export interface RawParsedMarkdown { 21 | frontmatter: unknown; 22 | body: string; 23 | } 24 | 25 | /** 26 | * Extract raw frontmatter without validation 27 | * Use this for --check mode to inspect invalid files without throwing 28 | */ 29 | export function parseRawFrontmatter(content: string): RawParsedMarkdown { 30 | const strippedContent = stripShebang(content); 31 | const lines = strippedContent.split("\n"); 32 | 33 | if (lines[0]?.trim() !== "---") { 34 | return { frontmatter: {}, body: strippedContent }; 35 | } 36 | 37 | let endIndex = -1; 38 | for (let i = 1; i < lines.length; i++) { 39 | if (lines[i]?.trim() === "---") { 40 | endIndex = i; 41 | break; 42 | } 43 | } 44 | 45 | if (endIndex === -1) { 46 | return { frontmatter: {}, body: content }; 47 | } 48 | 49 | const frontmatterYaml = lines.slice(1, endIndex).join("\n"); 50 | const body = lines.slice(endIndex + 1).join("\n").trim(); 51 | 52 | try { 53 | const parsed = yaml.load(frontmatterYaml); 54 | return { frontmatter: parsed ?? {}, body }; 55 | } catch (err) { 56 | if (err instanceof yaml.YAMLException) { 57 | throw new Error(`YAML parse error: ${err.message}`); 58 | } 59 | throw err; 60 | } 61 | } 62 | 63 | /** 64 | * Parse YAML frontmatter from markdown content 65 | * Automatically strips shebang line if present 66 | * Uses js-yaml for robust parsing and zod for validation 67 | */ 68 | export function parseFrontmatter(content: string): ParsedMarkdown { 69 | const { frontmatter: raw, body } = parseRawFrontmatter(content); 70 | 71 | // Handle empty frontmatter 72 | if (raw === null || raw === undefined || (typeof raw === 'object' && Object.keys(raw as object).length === 0)) { 73 | return { frontmatter: {}, body }; 74 | } 75 | 76 | // Validate against schema 77 | const frontmatter = validateFrontmatter(raw); 78 | 79 | return { frontmatter: frontmatter as AgentFrontmatter, body }; 80 | } 81 | 82 | /** 83 | * Parse YAML content directly (for testing or programmatic use) 84 | */ 85 | export function parseYaml(content: string): unknown { 86 | return yaml.load(content); 87 | } 88 | -------------------------------------------------------------------------------- /src/concurrency.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Concurrency utilities for limiting parallel operations. 3 | * 4 | * Provides a simple semaphore implementation to prevent file descriptor 5 | * exhaustion when processing many imports in parallel. 6 | */ 7 | 8 | /** 9 | * Default concurrency limit for parallel import resolution. 10 | * This prevents file descriptor exhaustion when processing many imports. 11 | */ 12 | export const DEFAULT_CONCURRENCY_LIMIT = 10; 13 | 14 | /** 15 | * Simple semaphore for limiting concurrent operations. 16 | * 17 | * This is used by the import resolution system to prevent opening too many 18 | * file handles or network connections simultaneously. 19 | * 20 | * @example 21 | * ```typescript 22 | * const semaphore = new Semaphore(5); // Allow 5 concurrent operations 23 | * 24 | * // Using run() for automatic acquire/release 25 | * const result = await semaphore.run(async () => { 26 | * return await fetchSomething(); 27 | * }); 28 | * 29 | * // Or manual acquire/release 30 | * await semaphore.acquire(); 31 | * try { 32 | * await doSomething(); 33 | * } finally { 34 | * semaphore.release(); 35 | * } 36 | * ``` 37 | */ 38 | export class Semaphore { 39 | private permits: number; 40 | private waiting: Array<() => void> = []; 41 | 42 | /** 43 | * Create a new semaphore with the specified number of permits. 44 | * 45 | * @param permits - Maximum number of concurrent operations allowed 46 | */ 47 | constructor(permits: number) { 48 | if (permits < 1) { 49 | throw new Error('Semaphore permits must be at least 1'); 50 | } 51 | this.permits = permits; 52 | } 53 | 54 | /** 55 | * Acquire a permit, blocking if none are available. 56 | * Returns immediately if a permit is available. 57 | */ 58 | async acquire(): Promise { 59 | if (this.permits > 0) { 60 | this.permits--; 61 | return; 62 | } 63 | 64 | return new Promise((resolve) => { 65 | this.waiting.push(resolve); 66 | }); 67 | } 68 | 69 | /** 70 | * Release a permit, allowing a waiting operation to proceed. 71 | */ 72 | release(): void { 73 | if (this.waiting.length > 0) { 74 | const next = this.waiting.shift()!; 75 | next(); 76 | } else { 77 | this.permits++; 78 | } 79 | } 80 | 81 | /** 82 | * Execute a function with semaphore protection. 83 | * Automatically acquires before and releases after execution. 84 | * 85 | * @param fn - The async function to execute 86 | * @returns The result of the function 87 | */ 88 | async run(fn: () => Promise): Promise { 89 | await this.acquire(); 90 | try { 91 | return await fn(); 92 | } finally { 93 | this.release(); 94 | } 95 | } 96 | 97 | /** 98 | * Get the number of currently available permits. 99 | */ 100 | get availablePermits(): number { 101 | return this.permits; 102 | } 103 | 104 | /** 105 | * Get the number of operations waiting for a permit. 106 | */ 107 | get waitingCount(): number { 108 | return this.waiting.length; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/explain.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from "bun:test"; 2 | import { analyzeAgent, formatExplainOutput } from "./explain"; 3 | import { join } from "path"; 4 | import { mkdtemp, rm, writeFile, mkdir } from "fs/promises"; 5 | import { tmpdir } from "os"; 6 | 7 | describe("explain", () => { 8 | let tempDir: string; 9 | 10 | beforeAll(async () => { 11 | tempDir = await mkdtemp(join(tmpdir(), "explain-test-")); 12 | }); 13 | 14 | afterAll(async () => { 15 | await rm(tempDir, { recursive: true, force: true }); 16 | }); 17 | 18 | describe("analyzeAgent", () => { 19 | it("analyzes a simple agent file", async () => { 20 | const agentPath = join(tempDir, "test.claude.md"); 21 | await writeFile(agentPath, `--- 22 | model: opus 23 | verbose: true 24 | --- 25 | Hello world`); 26 | 27 | const result = await analyzeAgent(agentPath); 28 | 29 | expect(result.command).toBe("claude"); 30 | expect(result.commandSource).toContain("Filename pattern"); 31 | expect(result.originalFrontmatter.model).toBe("opus"); 32 | expect(result.originalFrontmatter.verbose).toBe(true); 33 | expect(result.finalPrompt).toContain("Hello world"); 34 | }); 35 | 36 | it("detects interactive mode from filename", async () => { 37 | const agentPath = join(tempDir, "test.i.claude.md"); 38 | await writeFile(agentPath, `Test prompt`); 39 | 40 | const result = await analyzeAgent(agentPath); 41 | 42 | expect(result.interactiveMode).toBe(true); 43 | expect(result.interactiveModeSource).toContain("Filename"); 44 | }); 45 | 46 | it("extracts env keys with redacted values", async () => { 47 | const agentPath = join(tempDir, "env-test.claude.md"); 48 | await writeFile(agentPath, `--- 49 | _env: 50 | API_KEY: secret123 51 | OTHER_KEY: hidden 52 | --- 53 | Test`); 54 | 55 | const result = await analyzeAgent(agentPath); 56 | 57 | expect(result.envKeys).toContain("API_KEY"); 58 | expect(result.envKeys).toContain("OTHER_KEY"); 59 | }); 60 | 61 | it("includes token usage info", async () => { 62 | const agentPath = join(tempDir, "token-test.claude.md"); 63 | await writeFile(agentPath, `--- 64 | model: opus 65 | --- 66 | This is a test prompt with some words`); 67 | 68 | const result = await analyzeAgent(agentPath); 69 | 70 | expect(result.tokenUsage.tokens).toBeGreaterThan(0); 71 | expect(result.tokenUsage.limit).toBeGreaterThan(0); 72 | expect(result.tokenUsage.percentage).toBeGreaterThanOrEqual(0); 73 | }); 74 | }); 75 | 76 | describe("formatExplainOutput", () => { 77 | it("formats output with all sections", async () => { 78 | const agentPath = join(tempDir, "format-test.claude.md"); 79 | await writeFile(agentPath, `--- 80 | model: sonnet 81 | --- 82 | Test prompt`); 83 | 84 | const result = await analyzeAgent(agentPath); 85 | const output = formatExplainOutput(result); 86 | 87 | expect(output).toContain("MD EXPLAIN"); 88 | expect(output).toContain("COMMAND"); 89 | expect(output).toContain("MODE"); 90 | expect(output).toContain("CONFIGURATION PRECEDENCE"); 91 | expect(output).toContain("TOKEN USAGE"); 92 | expect(output).toContain("FINAL PROMPT"); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /scripts/bundle.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | /** 3 | * Bundle mdflow codebase for AI upload 4 | * Creates a compressed markdown bundle suitable for pasting into AI models 5 | * 6 | * Usage: 7 | * bun run scripts/bundle.ts # Full bundle to stdout 8 | * bun run scripts/bundle.ts -o # Full bundle to mdflow-bundle.md 9 | * bun run scripts/bundle.ts --core # Core files only (~30k tokens) 10 | * bun run bundle # Via npm script (full, to file) 11 | * bun run bundle:core # Via npm script (core only) 12 | */ 13 | 14 | import { $ } from "bun"; 15 | import { join } from "path"; 16 | import { existsSync } from "fs"; 17 | 18 | const projectRoot = join(import.meta.dir, ".."); 19 | const outputArg = process.argv.includes("-o") || process.argv.includes("--output"); 20 | const coreOnly = process.argv.includes("--core"); 21 | const outputFile = coreOnly ? "mdflow-core-bundle.md" : "mdflow-bundle.md"; 22 | 23 | // Core files - the essential modules for understanding mdflow 24 | const coreFiles = [ 25 | // Entry + CLI 26 | "src/index.ts", 27 | "src/cli.ts", 28 | "src/cli-runner.ts", 29 | 30 | // Core execution 31 | "src/command.ts", 32 | "src/command-builder.ts", 33 | "src/runtime.ts", 34 | 35 | // Parsing & templates 36 | "src/parse.ts", 37 | "src/template.ts", 38 | "src/schema.ts", 39 | "src/types.ts", 40 | 41 | // Imports system 42 | "src/imports.ts", 43 | "src/imports-parser.ts", 44 | "src/imports-resolver.ts", 45 | "src/imports-types.ts", 46 | 47 | // Config & env 48 | "src/config.ts", 49 | "src/env.ts", 50 | 51 | // Adapters 52 | "src/adapters/index.ts", 53 | "src/adapters/claude.ts", 54 | "src/adapters/gemini.ts", 55 | 56 | // Docs 57 | "CLAUDE.md", 58 | "README.md", 59 | "package.json", 60 | ]; 61 | 62 | // Build the packx command 63 | const args: string[] = []; 64 | 65 | if (coreOnly) { 66 | // Core mode: specific files only 67 | args.push(...coreFiles); 68 | } else { 69 | // Full mode: all source files except tests 70 | args.push("src/**/*.ts"); 71 | args.push("-x", "*.test.ts"); 72 | args.push("CLAUDE.md", "README.md", "package.json", "tsconfig.json"); 73 | } 74 | 75 | // Compression options 76 | args.push("--minify"); // Remove empty lines 77 | args.push("--strip-comments"); // Remove comments 78 | 79 | // Output format 80 | args.push("-f", "markdown"); 81 | 82 | // Non-interactive for scripting 83 | args.push("--no-interactive"); 84 | 85 | if (outputArg) { 86 | args.push("-o", outputFile); 87 | } 88 | 89 | const mode = coreOnly ? "core (~30k tokens)" : "full (~75k tokens)"; 90 | console.error("📦 Bundling mdflow codebase for AI upload..."); 91 | console.error(` Mode: ${mode}`); 92 | console.error(` Output: ${outputArg ? outputFile : "stdout"}`); 93 | console.error(""); 94 | 95 | try { 96 | await $`packx ${args}`.cwd(projectRoot); 97 | 98 | if (outputArg && existsSync(join(projectRoot, outputFile))) { 99 | const content = await Bun.file(join(projectRoot, outputFile)).text(); 100 | const lines = content.split("\n").length; 101 | console.error(`\n✅ Bundle created: ${outputFile}`); 102 | console.error(` Lines: ${lines.toLocaleString()}`); 103 | } 104 | } catch (error) { 105 | console.error("❌ Bundle failed:", error); 106 | process.exit(1); 107 | } 108 | -------------------------------------------------------------------------------- /src/env.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test"; 2 | import { loadEnvFiles, getEnvFilesInDirectory } from "./env"; 3 | import { mkdtemp, rm } from "node:fs/promises"; 4 | import { tmpdir } from "node:os"; 5 | import { join } from "node:path"; 6 | 7 | let testDir: string; 8 | let originalEnv: Record; 9 | 10 | beforeAll(async () => { 11 | testDir = await mkdtemp(join(tmpdir(), "env-test-")); 12 | }); 13 | 14 | afterAll(async () => { 15 | await rm(testDir, { recursive: true }); 16 | }); 17 | 18 | beforeEach(() => { 19 | // Save original env 20 | originalEnv = { ...process.env }; 21 | }); 22 | 23 | afterEach(() => { 24 | // Restore original env 25 | for (const key of Object.keys(process.env)) { 26 | if (!(key in originalEnv)) { 27 | delete process.env[key]; 28 | } 29 | } 30 | for (const [key, value] of Object.entries(originalEnv)) { 31 | if (value !== undefined) { 32 | process.env[key] = value; 33 | } 34 | } 35 | }); 36 | 37 | test("loadEnvFiles loads .env file", async () => { 38 | await Bun.write(join(testDir, ".env"), "TEST_VAR=hello"); 39 | 40 | const count = await loadEnvFiles(testDir); 41 | 42 | expect(count).toBe(1); 43 | expect(process.env.TEST_VAR).toBe("hello"); 44 | }); 45 | 46 | test("loadEnvFiles handles quoted values", async () => { 47 | await Bun.write(join(testDir, ".env"), ` 48 | DOUBLE_QUOTED="hello world" 49 | SINGLE_QUOTED='hello world' 50 | `); 51 | 52 | await loadEnvFiles(testDir); 53 | 54 | expect(process.env.DOUBLE_QUOTED).toBe("hello world"); 55 | expect(process.env.SINGLE_QUOTED).toBe("hello world"); 56 | }); 57 | 58 | test("loadEnvFiles ignores comments", async () => { 59 | await Bun.write(join(testDir, ".env"), ` 60 | # This is a comment 61 | KEY=value 62 | # Another comment 63 | `); 64 | 65 | await loadEnvFiles(testDir); 66 | 67 | expect(process.env.KEY).toBe("value"); 68 | }); 69 | 70 | test("loadEnvFiles does not override existing env vars", async () => { 71 | process.env.EXISTING_VAR = "original"; 72 | await Bun.write(join(testDir, ".env"), "EXISTING_VAR=new"); 73 | 74 | await loadEnvFiles(testDir); 75 | 76 | expect(process.env.EXISTING_VAR).toBe("original"); 77 | }); 78 | 79 | test("loadEnvFiles loads multiple files in order", async () => { 80 | await Bun.write(join(testDir, ".env"), "BASE=base\nOVERRIDE=base"); 81 | await Bun.write(join(testDir, ".env.local"), "OVERRIDE=local"); 82 | 83 | const count = await loadEnvFiles(testDir); 84 | 85 | expect(count).toBe(2); 86 | expect(process.env.BASE).toBe("base"); 87 | expect(process.env.OVERRIDE).toBe("local"); 88 | }); 89 | 90 | test("loadEnvFiles handles inline comments", async () => { 91 | await Bun.write(join(testDir, ".env"), "KEY=value # this is a comment"); 92 | 93 | await loadEnvFiles(testDir); 94 | 95 | expect(process.env.KEY).toBe("value"); 96 | }); 97 | 98 | test("getEnvFilesInDirectory lists existing files", async () => { 99 | await Bun.write(join(testDir, ".env"), "A=1"); 100 | await Bun.write(join(testDir, ".env.local"), "B=2"); 101 | 102 | const files = await getEnvFilesInDirectory(testDir); 103 | 104 | expect(files).toContain(".env"); 105 | expect(files).toContain(".env.local"); 106 | }); 107 | 108 | test("loadEnvFiles returns 0 for directory with no env files", async () => { 109 | const emptyDir = await mkdtemp(join(tmpdir(), "env-empty-")); 110 | 111 | const count = await loadEnvFiles(emptyDir); 112 | 113 | expect(count).toBe(0); 114 | 115 | await rm(emptyDir, { recursive: true }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/binary-check.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, mock, spyOn, beforeEach, afterEach } from "bun:test"; 2 | import { runCommand, type RunContext } from "./command"; 3 | 4 | describe("runCommand binary check", () => { 5 | // Store original console.error 6 | let consoleErrorSpy: ReturnType; 7 | let errorMessages: string[] = []; 8 | 9 | beforeEach(() => { 10 | errorMessages = []; 11 | consoleErrorSpy = spyOn(console, "error").mockImplementation((msg: string) => { 12 | errorMessages.push(msg); 13 | }); 14 | }); 15 | 16 | afterEach(() => { 17 | consoleErrorSpy.mockRestore(); 18 | }); 19 | 20 | test("returns exit code 127 when command not found", async () => { 21 | const ctx: RunContext = { 22 | command: "nonexistent-command-xyz-123", 23 | args: [], 24 | positionals: [], 25 | positionalMappings: new Map(), 26 | captureOutput: false, 27 | }; 28 | 29 | const result = await runCommand(ctx); 30 | 31 | expect(result.exitCode).toBe(127); 32 | expect(result.output).toBe(""); 33 | }); 34 | 35 | test("prints helpful error message when command not found", async () => { 36 | const ctx: RunContext = { 37 | command: "fake-missing-binary", 38 | args: [], 39 | positionals: [], 40 | positionalMappings: new Map(), 41 | captureOutput: false, 42 | }; 43 | 44 | await runCommand(ctx); 45 | 46 | expect(errorMessages.length).toBe(3); 47 | expect(errorMessages[0]).toContain("Command not found: 'fake-missing-binary'"); 48 | expect(errorMessages[1]).toContain("fake-missing-binary"); 49 | expect(errorMessages[1]).toContain("installed"); 50 | expect(errorMessages[2]).toContain("install"); 51 | }); 52 | 53 | test("executes successfully when command exists", async () => { 54 | const ctx: RunContext = { 55 | command: "echo", 56 | args: ["hello"], 57 | positionals: [], 58 | positionalMappings: new Map(), 59 | captureOutput: true, 60 | }; 61 | 62 | const result = await runCommand(ctx); 63 | 64 | expect(result.exitCode).toBe(0); 65 | expect(result.output.trim()).toBe("hello"); 66 | }); 67 | 68 | test("passes positionals correctly when command exists", async () => { 69 | const ctx: RunContext = { 70 | command: "echo", 71 | args: [], 72 | positionals: ["world"], 73 | positionalMappings: new Map(), 74 | captureOutput: true, 75 | }; 76 | 77 | const result = await runCommand(ctx); 78 | 79 | expect(result.exitCode).toBe(0); 80 | expect(result.output.trim()).toBe("world"); 81 | }); 82 | 83 | test("maps positionals to flags when command exists", async () => { 84 | const ctx: RunContext = { 85 | command: "echo", 86 | args: [], 87 | positionals: ["test-value"], 88 | positionalMappings: new Map([[1, "n"]]), // echo -n suppresses newline 89 | captureOutput: true, 90 | }; 91 | 92 | const result = await runCommand(ctx); 93 | 94 | // echo -n test-value should output without newline 95 | expect(result.exitCode).toBe(0); 96 | expect(result.output).toBe("test-value"); 97 | }); 98 | 99 | test("handles environment variables when command exists", async () => { 100 | const ctx: RunContext = { 101 | command: "sh", 102 | args: ["-c", "echo $TEST_VAR"], 103 | positionals: [], 104 | positionalMappings: new Map(), 105 | captureOutput: true, 106 | env: { TEST_VAR: "hello-env" }, 107 | }; 108 | 109 | const result = await runCommand(ctx); 110 | 111 | expect(result.exitCode).toBe(0); 112 | expect(result.output.trim()).toBe("hello-env"); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/concurrency.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from "bun:test"; 2 | import { Semaphore, DEFAULT_CONCURRENCY_LIMIT } from "./concurrency"; 3 | 4 | describe("Semaphore", () => { 5 | test("allows operations up to permit limit", async () => { 6 | const semaphore = new Semaphore(2); 7 | let concurrentCount = 0; 8 | let maxConcurrent = 0; 9 | 10 | const operation = async (id: number) => { 11 | await semaphore.acquire(); 12 | concurrentCount++; 13 | maxConcurrent = Math.max(maxConcurrent, concurrentCount); 14 | // Simulate some async work 15 | await new Promise((resolve) => setTimeout(resolve, 10)); 16 | concurrentCount--; 17 | semaphore.release(); 18 | return id; 19 | }; 20 | 21 | // Start 5 operations 22 | const results = await Promise.all([ 23 | operation(1), 24 | operation(2), 25 | operation(3), 26 | operation(4), 27 | operation(5), 28 | ]); 29 | 30 | expect(results).toEqual([1, 2, 3, 4, 5]); 31 | expect(maxConcurrent).toBeLessThanOrEqual(2); 32 | }); 33 | 34 | test("run() auto-releases on success", async () => { 35 | const semaphore = new Semaphore(1); 36 | 37 | const result = await semaphore.run(async () => { 38 | return "success"; 39 | }); 40 | 41 | expect(result).toBe("success"); 42 | expect(semaphore.availablePermits).toBe(1); 43 | }); 44 | 45 | test("run() auto-releases on error", async () => { 46 | const semaphore = new Semaphore(1); 47 | 48 | await expect( 49 | semaphore.run(async () => { 50 | throw new Error("test error"); 51 | }) 52 | ).rejects.toThrow("test error"); 53 | 54 | // Permit should be released even after error 55 | expect(semaphore.availablePermits).toBe(1); 56 | }); 57 | 58 | test("throws on invalid permit count", () => { 59 | expect(() => new Semaphore(0)).toThrow("Semaphore permits must be at least 1"); 60 | expect(() => new Semaphore(-1)).toThrow("Semaphore permits must be at least 1"); 61 | }); 62 | 63 | test("waitingCount tracks waiting operations", async () => { 64 | const semaphore = new Semaphore(1); 65 | expect(semaphore.waitingCount).toBe(0); 66 | 67 | // Acquire the only permit 68 | await semaphore.acquire(); 69 | expect(semaphore.availablePermits).toBe(0); 70 | 71 | // Start a waiting operation 72 | const waitingPromise = semaphore.acquire(); 73 | expect(semaphore.waitingCount).toBe(1); 74 | 75 | // Release to let the waiting operation proceed 76 | semaphore.release(); 77 | await waitingPromise; 78 | expect(semaphore.waitingCount).toBe(0); 79 | 80 | // Clean up 81 | semaphore.release(); 82 | }); 83 | 84 | test("limits concurrent operations with run()", async () => { 85 | const semaphore = new Semaphore(3); 86 | let concurrentCount = 0; 87 | let maxConcurrent = 0; 88 | 89 | const operation = (id: number) => 90 | semaphore.run(async () => { 91 | concurrentCount++; 92 | maxConcurrent = Math.max(maxConcurrent, concurrentCount); 93 | await new Promise((resolve) => setTimeout(resolve, 10)); 94 | concurrentCount--; 95 | return id; 96 | }); 97 | 98 | // Start 10 operations 99 | const results = await Promise.all( 100 | Array.from({ length: 10 }, (_, i) => operation(i)) 101 | ); 102 | 103 | expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 104 | expect(maxConcurrent).toBeLessThanOrEqual(3); 105 | }); 106 | }); 107 | 108 | describe("DEFAULT_CONCURRENCY_LIMIT", () => { 109 | test("is a reasonable default", () => { 110 | expect(DEFAULT_CONCURRENCY_LIMIT).toBe(10); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /src/imports-types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Import Action Types - Output of the pure parser (Phase 1) 3 | * 4 | * These represent the different types of imports found when scanning content, 5 | * before any I/O operations are performed. 6 | */ 7 | 8 | /** File import with optional line range */ 9 | export interface FileImportAction { 10 | type: 'file'; 11 | path: string; 12 | lineRange?: { start: number; end: number }; 13 | /** Original matched text for replacement */ 14 | original: string; 15 | /** Position in the original string */ 16 | index: number; 17 | } 18 | 19 | /** Glob pattern import */ 20 | export interface GlobImportAction { 21 | type: 'glob'; 22 | pattern: string; 23 | /** Original matched text for replacement */ 24 | original: string; 25 | /** Position in the original string */ 26 | index: number; 27 | } 28 | 29 | /** URL import */ 30 | export interface UrlImportAction { 31 | type: 'url'; 32 | url: string; 33 | /** Original matched text for replacement */ 34 | original: string; 35 | /** Position in the original string */ 36 | index: number; 37 | } 38 | 39 | /** Command inline */ 40 | export interface CommandImportAction { 41 | type: 'command'; 42 | command: string; 43 | /** Original matched text for replacement */ 44 | original: string; 45 | /** Position in the original string */ 46 | index: number; 47 | } 48 | 49 | /** Symbol extraction import */ 50 | export interface SymbolImportAction { 51 | type: 'symbol'; 52 | path: string; 53 | symbol: string; 54 | /** Original matched text for replacement */ 55 | original: string; 56 | /** Position in the original string */ 57 | index: number; 58 | } 59 | 60 | /** Executable Code Fence Action */ 61 | export interface ExecutableCodeFenceAction { 62 | type: 'executable_code_fence'; 63 | shebang: string; // "#!/usr/bin/env bun" 64 | language: string; // "ts", "js", "python" 65 | code: string; // Code content (without shebang) 66 | original: string; // Full match including fence markers 67 | index: number; 68 | } 69 | 70 | /** Union of all import action types */ 71 | export type ImportAction = 72 | | FileImportAction 73 | | GlobImportAction 74 | | UrlImportAction 75 | | CommandImportAction 76 | | SymbolImportAction 77 | | ExecutableCodeFenceAction; 78 | 79 | /** 80 | * Resolved Import - Output of the resolver (Phase 2) 81 | * 82 | * Contains the original action plus the resolved content. 83 | */ 84 | export interface ResolvedImport { 85 | /** The original action that was resolved */ 86 | action: ImportAction; 87 | /** The resolved content to inject */ 88 | content: string; 89 | } 90 | 91 | /** 92 | * System Environment interface for the resolver 93 | * Abstracts away file system and network operations for testability 94 | */ 95 | export interface SystemEnvironment { 96 | /** Read a file's content */ 97 | readFile(path: string): Promise; 98 | /** Check if a file exists */ 99 | fileExists(path: string): Promise; 100 | /** Get file size in bytes */ 101 | fileSize(path: string): Promise; 102 | /** Check if a file is binary */ 103 | isBinaryFile(path: string): Promise; 104 | /** Resolve a path to canonical form (resolving symlinks) */ 105 | toCanonicalPath(path: string): string; 106 | /** Fetch URL content */ 107 | fetchUrl(url: string): Promise<{ content: string; contentType: string | null }>; 108 | /** Execute a shell command */ 109 | execCommand(command: string, cwd: string): Promise; 110 | /** Expand glob pattern and return matching files */ 111 | expandGlob(pattern: string, cwd: string): Promise>; 112 | /** Current working directory */ 113 | cwd: string; 114 | /** Whether to log verbose output */ 115 | verbose: boolean; 116 | /** Log a message (for verbose output) */ 117 | log(message: string): void; 118 | } 119 | -------------------------------------------------------------------------------- /src/crash-log.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeEach, afterEach } from "bun:test"; 2 | import { spawnSync } from "child_process"; 3 | import { mkdirSync, writeFileSync, rmSync } from "fs"; 4 | import { join } from "path"; 5 | import { tmpdir } from "os"; 6 | import { getAgentLogPath } from "./logger"; 7 | 8 | const TEST_DIR = join(tmpdir(), "md-crash-log-test"); 9 | const CLI_PATH = join(import.meta.dir, "index.ts"); 10 | 11 | describe("crash log pointer", () => { 12 | beforeEach(() => { 13 | mkdirSync(TEST_DIR, { recursive: true }); 14 | }); 15 | 16 | afterEach(() => { 17 | rmSync(TEST_DIR, { recursive: true, force: true }); 18 | }); 19 | 20 | test("shows log path on non-zero exit code from command", () => { 21 | // Create an agent that runs a command that will fail 22 | const agentFile = join(TEST_DIR, "fail.echo.md"); 23 | writeFileSync(agentFile, `--- 24 | --- 25 | This will run echo (which exists) but we'll check for log path in output 26 | `); 27 | 28 | // Run with a command that doesn't exist to force failure 29 | const result = spawnSync("bun", [CLI_PATH, agentFile, "--_command", "nonexistent-command-xyz"], { 30 | encoding: "utf-8", 31 | env: { ...process.env, PATH: process.env.PATH }, 32 | }); 33 | 34 | // Should have non-zero exit code 35 | expect(result.status).not.toBe(0); 36 | 37 | // Stderr should mention "Detailed logs:" with a path 38 | const stderr = result.stderr || ""; 39 | // The command might fail before logger init or after - both are valid 40 | // If it fails before logger init, no log path is shown (which is correct) 41 | // If it fails after, log path should be shown 42 | expect(stderr.length).toBeGreaterThan(0); 43 | }); 44 | 45 | test("shows log path on missing template variables", () => { 46 | // Create an agent with required underscore-prefixed template variable but don't provide it 47 | const agentFile = join(TEST_DIR, "missing-var.claude.md"); 48 | writeFileSync(agentFile, `--- 49 | --- 50 | Hello {{ _required_var }} 51 | `); 52 | 53 | const result = spawnSync("bun", [CLI_PATH, agentFile], { 54 | encoding: "utf-8", 55 | }); 56 | 57 | expect(result.status).toBe(1); 58 | 59 | const stderr = result.stderr || ""; 60 | expect(stderr).toContain("Missing template variables"); 61 | expect(stderr).toContain("Detailed logs:"); 62 | expect(stderr).toContain(".mdflow/logs"); 63 | }); 64 | 65 | test("shows log path on import error", () => { 66 | // Create an agent with a bad import 67 | const agentFile = join(TEST_DIR, "bad-import.claude.md"); 68 | writeFileSync(agentFile, `--- 69 | --- 70 | @./nonexistent-file-xyz.txt 71 | `); 72 | 73 | const result = spawnSync("bun", [CLI_PATH, agentFile], { 74 | encoding: "utf-8", 75 | }); 76 | 77 | expect(result.status).toBe(1); 78 | 79 | const stderr = result.stderr || ""; 80 | expect(stderr).toContain("Import error"); 81 | expect(stderr).toContain("Detailed logs:"); 82 | }); 83 | 84 | test("getAgentLogPath returns expected path format", () => { 85 | const path = getAgentLogPath("test.claude.md"); 86 | expect(path).toContain(".mdflow/logs"); 87 | expect(path).toContain("test-claude"); 88 | expect(path).toContain("debug.log"); 89 | }); 90 | 91 | test("does not show log path for successful execution", () => { 92 | // Create a simple agent that runs echo successfully 93 | const agentFile = join(TEST_DIR, "success.echo.md"); 94 | writeFileSync(agentFile, `--- 95 | --- 96 | Hello world 97 | `); 98 | 99 | const result = spawnSync("bun", [CLI_PATH, agentFile], { 100 | encoding: "utf-8", 101 | }); 102 | 103 | expect(result.status).toBe(0); 104 | 105 | const stderr = result.stderr || ""; 106 | // Should NOT contain "Detailed logs" on success 107 | expect(stderr).not.toContain("Detailed logs:"); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/failure-menu.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for failure-menu.ts 3 | */ 4 | 5 | import { describe, it, expect } from "bun:test"; 6 | import { buildFixPrompt } from "./failure-menu"; 7 | 8 | describe("buildFixPrompt", () => { 9 | it("includes exit code in the fix prompt", () => { 10 | const result = buildFixPrompt( 11 | "original prompt", 12 | "error message", 13 | "some output", 14 | 42 15 | ); 16 | 17 | expect(result).toContain("Exit code: 42"); 18 | }); 19 | 20 | it("includes stderr when provided", () => { 21 | const result = buildFixPrompt( 22 | "original prompt", 23 | "Permission denied: cannot write to /etc/hosts", 24 | "", 25 | 1 26 | ); 27 | 28 | expect(result).toContain("--- STDERR ---"); 29 | expect(result).toContain("Permission denied: cannot write to /etc/hosts"); 30 | expect(result).toContain("--- END STDERR ---"); 31 | }); 32 | 33 | it("includes stdout when provided", () => { 34 | const result = buildFixPrompt( 35 | "original prompt", 36 | "", 37 | "Build started...\nCompiling...", 38 | 1 39 | ); 40 | 41 | expect(result).toContain("--- STDOUT (partial) ---"); 42 | expect(result).toContain("Build started..."); 43 | expect(result).toContain("Compiling..."); 44 | expect(result).toContain("--- END STDOUT ---"); 45 | }); 46 | 47 | it("includes the original prompt at the end", () => { 48 | const originalPrompt = "Write a function to calculate fibonacci numbers"; 49 | const result = buildFixPrompt( 50 | originalPrompt, 51 | "syntax error", 52 | "", 53 | 1 54 | ); 55 | 56 | expect(result).toContain("Original request:"); 57 | expect(result).toContain(originalPrompt); 58 | }); 59 | 60 | it("truncates long stdout to 2000 chars", () => { 61 | const longOutput = "x".repeat(3000); 62 | const result = buildFixPrompt( 63 | "original prompt", 64 | "", 65 | longOutput, 66 | 1 67 | ); 68 | 69 | // Should contain truncation indicator 70 | expect(result).toContain("(truncated)"); 71 | // Should not contain the full 3000 chars 72 | expect(result.length).toBeLessThan(3000 + 500); // 500 for other content 73 | }); 74 | 75 | it("handles empty stderr and stdout", () => { 76 | const result = buildFixPrompt( 77 | "original prompt", 78 | "", 79 | "", 80 | 1 81 | ); 82 | 83 | expect(result).not.toContain("--- STDERR ---"); 84 | expect(result).not.toContain("--- STDOUT ---"); 85 | expect(result).toContain("Exit code: 1"); 86 | expect(result).toContain("original prompt"); 87 | }); 88 | 89 | it("includes fix instruction", () => { 90 | const result = buildFixPrompt( 91 | "original prompt", 92 | "error", 93 | "", 94 | 1 95 | ); 96 | 97 | expect(result).toContain("The previous command failed"); 98 | expect(result).toContain("analyze the error"); 99 | expect(result).toContain("fix"); 100 | }); 101 | 102 | it("handles both stderr and stdout together", () => { 103 | const result = buildFixPrompt( 104 | "run tests", 105 | "Test failed: assertion error", 106 | "Running test suite...\n10 tests found", 107 | 1 108 | ); 109 | 110 | expect(result).toContain("--- STDERR ---"); 111 | expect(result).toContain("Test failed: assertion error"); 112 | expect(result).toContain("--- STDOUT (partial) ---"); 113 | expect(result).toContain("Running test suite..."); 114 | expect(result).toContain("Original request:"); 115 | expect(result).toContain("run tests"); 116 | }); 117 | 118 | it("preserves multiline stderr formatting", () => { 119 | const multilineStderr = `Error: Module not found 120 | at require (/app/index.js:10:5) 121 | at main (/app/index.js:20:3)`; 122 | 123 | const result = buildFixPrompt( 124 | "original prompt", 125 | multilineStderr, 126 | "", 127 | 1 128 | ); 129 | 130 | expect(result).toContain("Error: Module not found"); 131 | expect(result).toContain("at require"); 132 | expect(result).toContain("at main"); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tool Adapter Registry 3 | * 4 | * Central registry for tool adapters. Adapters define tool-specific behavior 5 | * for default configuration and interactive mode transformations. 6 | * 7 | * Adding a new tool requires: 8 | * 1. Creating a new adapter file (e.g., src/adapters/mytool.ts) 9 | * 2. Registering it in this file 10 | * 11 | * The registry provides a fallback "default" adapter for unknown tools. 12 | */ 13 | 14 | import type { ToolAdapter, CommandDefaults, AgentFrontmatter } from "../types"; 15 | 16 | // Import built-in adapters 17 | import { claudeAdapter } from "./claude"; 18 | import { copilotAdapter } from "./copilot"; 19 | import { codexAdapter } from "./codex"; 20 | import { geminiAdapter } from "./gemini"; 21 | import { droidAdapter } from "./droid"; 22 | import { opencodeAdapter } from "./opencode"; 23 | 24 | /** 25 | * Default adapter for unknown tools 26 | * Provides no defaults and no-op interactive transformation 27 | */ 28 | const defaultAdapter: ToolAdapter = { 29 | name: "default", 30 | 31 | getDefaults(): CommandDefaults { 32 | return {}; 33 | }, 34 | 35 | applyInteractiveMode(frontmatter: AgentFrontmatter): AgentFrontmatter { 36 | // Unknown command - no special transformations needed 37 | return { ...frontmatter }; 38 | }, 39 | }; 40 | 41 | /** 42 | * Registry of tool adapters indexed by tool name 43 | */ 44 | const adapterRegistry: Map = new Map(); 45 | 46 | /** 47 | * Register a tool adapter 48 | * @param adapter - The adapter to register 49 | */ 50 | export function registerAdapter(adapter: ToolAdapter): void { 51 | adapterRegistry.set(adapter.name, adapter); 52 | } 53 | 54 | /** 55 | * Get the adapter for a specific tool 56 | * Returns the default adapter if no specific adapter is registered 57 | * 58 | * @param toolName - The name of the tool (e.g., "claude", "copilot") 59 | * @returns The tool adapter 60 | */ 61 | export function getAdapter(toolName: string): ToolAdapter { 62 | return adapterRegistry.get(toolName) ?? defaultAdapter; 63 | } 64 | 65 | /** 66 | * Check if an adapter is registered for a tool 67 | * @param toolName - The name of the tool 68 | * @returns true if a specific adapter exists (not the default) 69 | */ 70 | export function hasAdapter(toolName: string): boolean { 71 | return adapterRegistry.has(toolName); 72 | } 73 | 74 | /** 75 | * Get all registered adapter names 76 | * @returns Array of registered tool names 77 | */ 78 | export function getRegisteredAdapters(): string[] { 79 | return Array.from(adapterRegistry.keys()); 80 | } 81 | 82 | /** 83 | * Get the default adapter (for unknown tools) 84 | * @returns The default adapter 85 | */ 86 | export function getDefaultAdapter(): ToolAdapter { 87 | return defaultAdapter; 88 | } 89 | 90 | /** 91 | * Build the BUILTIN_DEFAULTS object from all registered adapters 92 | * This generates the same structure as the previous hardcoded BUILTIN_DEFAULTS 93 | * 94 | * @returns GlobalConfig-compatible commands object 95 | */ 96 | export function buildBuiltinDefaults(): Record { 97 | const commands: Record = {}; 98 | 99 | for (const [name, adapter] of adapterRegistry) { 100 | const defaults = adapter.getDefaults(); 101 | // Only include adapters that have non-empty defaults 102 | if (Object.keys(defaults).length > 0) { 103 | commands[name] = defaults; 104 | } 105 | } 106 | 107 | return commands; 108 | } 109 | 110 | /** 111 | * Clear the registry (for testing) 112 | */ 113 | export function clearAdapterRegistry(): void { 114 | adapterRegistry.clear(); 115 | } 116 | 117 | /** 118 | * Initialize the registry with built-in adapters 119 | * Called automatically on module load 120 | */ 121 | function initializeBuiltinAdapters(): void { 122 | registerAdapter(claudeAdapter); 123 | registerAdapter(copilotAdapter); 124 | registerAdapter(codexAdapter); 125 | registerAdapter(geminiAdapter); 126 | registerAdapter(droidAdapter); 127 | registerAdapter(opencodeAdapter); 128 | } 129 | 130 | // Initialize built-in adapters on module load 131 | initializeBuiltinAdapters(); 132 | 133 | // Re-export for convenience 134 | export { defaultAdapter }; 135 | export type { ToolAdapter } from "../types"; 136 | -------------------------------------------------------------------------------- /src/streams.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * IO Streams utilities for stdin abstraction 3 | * 4 | * Provides helpers to create IOStreams instances for testing and production use. 5 | * Enables testing piping scenarios without real shell pipes. 6 | */ 7 | 8 | import { Readable, Writable } from "stream"; 9 | import type { IOStreams } from "./types"; 10 | import { MAX_INPUT_SIZE, StdinSizeLimitError, exceedsLimit } from "./limits"; 11 | 12 | /** 13 | * Create the default IO streams using process.stdin/stdout/stderr 14 | */ 15 | export function createDefaultStreams(): IOStreams { 16 | return { 17 | stdin: process.stdin.isTTY ? null : process.stdin, 18 | stdout: process.stdout, 19 | stderr: process.stderr, 20 | isTTY: process.stdin.isTTY ?? false, 21 | }; 22 | } 23 | 24 | /** 25 | * Create a readable stream from a string 26 | * Useful for testing piped input scenarios 27 | */ 28 | export function stringToStream(content: string): NodeJS.ReadableStream { 29 | const readable = new Readable({ 30 | read() { 31 | this.push(Buffer.from(content, "utf-8")); 32 | this.push(null); 33 | }, 34 | }); 35 | return readable; 36 | } 37 | 38 | /** 39 | * Create a writable stream that collects output to a string 40 | * Useful for capturing stdout/stderr in tests 41 | */ 42 | export function createCaptureStream(): { stream: NodeJS.WritableStream; getOutput: () => string } { 43 | const chunks: Buffer[] = []; 44 | const stream = new Writable({ 45 | write(chunk, _encoding, callback) { 46 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); 47 | callback(); 48 | }, 49 | }); 50 | 51 | return { 52 | stream, 53 | getOutput: () => Buffer.concat(chunks).toString("utf-8"), 54 | }; 55 | } 56 | 57 | /** 58 | * Create IOStreams for testing with simulated stdin 59 | * 60 | * @param stdinContent - Content to simulate as piped stdin (null for TTY mode) 61 | * @returns IOStreams instance with capture streams for stdout/stderr 62 | */ 63 | export function createTestStreams(stdinContent: string | null = null): { 64 | streams: IOStreams; 65 | getStdout: () => string; 66 | getStderr: () => string; 67 | } { 68 | const stdout = createCaptureStream(); 69 | const stderr = createCaptureStream(); 70 | 71 | return { 72 | streams: { 73 | stdin: stdinContent !== null ? stringToStream(stdinContent) : null, 74 | stdout: stdout.stream, 75 | stderr: stderr.stream, 76 | isTTY: stdinContent === null, 77 | }, 78 | getStdout: stdout.getOutput, 79 | getStderr: stderr.getOutput, 80 | }; 81 | } 82 | 83 | /** 84 | * Read all content from an input stream with size limit enforcement 85 | * 86 | * @param stream - Readable stream to consume 87 | * @returns Promise resolving to the stream content as a string 88 | * @throws StdinSizeLimitError if content exceeds MAX_INPUT_SIZE 89 | */ 90 | export async function readStream(stream: NodeJS.ReadableStream): Promise { 91 | const chunks: Buffer[] = []; 92 | let totalBytes = 0; 93 | 94 | for await (const chunk of stream) { 95 | const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); 96 | totalBytes += buffer.length; 97 | if (exceedsLimit(totalBytes)) { 98 | throw new StdinSizeLimitError(totalBytes); 99 | } 100 | chunks.push(buffer); 101 | } 102 | 103 | return Buffer.concat(chunks).toString("utf-8").trim(); 104 | } 105 | 106 | /** 107 | * Read stdin content from IOStreams 108 | * Returns empty string if stdin is null (TTY mode) 109 | * 110 | * @param streams - IOStreams instance 111 | * @returns Promise resolving to stdin content or empty string 112 | */ 113 | export async function readStdinFromStreams(streams: IOStreams): Promise { 114 | if (!streams.stdin) { 115 | return ""; 116 | } 117 | return readStream(streams.stdin); 118 | } 119 | 120 | /** 121 | * Check if streams indicate TTY (interactive) mode 122 | */ 123 | export function isInteractive(streams: IOStreams): boolean { 124 | return streams.isTTY; 125 | } 126 | 127 | /** 128 | * Write to stdout stream 129 | */ 130 | export function writeStdout(streams: IOStreams, content: string): void { 131 | streams.stdout.write(content); 132 | } 133 | 134 | /** 135 | * Write to stderr stream 136 | */ 137 | export function writeStderr(streams: IOStreams, content: string): void { 138 | streams.stderr.write(content); 139 | } 140 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Typed error classes for mdflow 3 | * 4 | * These errors allow library code to signal failures without calling process.exit(), 5 | * enabling proper error handling in tests and when used as a library. 6 | * 7 | * Only the main entry point (index.ts) should catch these and set exit codes. 8 | */ 9 | 10 | /** 11 | * Base error class for all mdflow errors 12 | */ 13 | export class MarkdownAgentError extends Error { 14 | constructor(message: string, public code: number = 1) { 15 | super(message); 16 | this.name = "MarkdownAgentError"; 17 | // Maintains proper stack trace for where error was thrown (only in V8) 18 | if (Error.captureStackTrace) { 19 | Error.captureStackTrace(this, this.constructor); 20 | } 21 | } 22 | } 23 | 24 | /** 25 | * Configuration-related errors (invalid config, missing required fields, etc.) 26 | */ 27 | export class ConfigurationError extends MarkdownAgentError { 28 | constructor(message: string, code: number = 1) { 29 | super(message, code); 30 | this.name = "ConfigurationError"; 31 | } 32 | } 33 | 34 | /** 35 | * Security-related errors (untrusted domains, trust verification failures) 36 | */ 37 | export class SecurityError extends MarkdownAgentError { 38 | constructor(message: string, code: number = 1) { 39 | super(message, code); 40 | this.name = "SecurityError"; 41 | } 42 | } 43 | 44 | /** 45 | * Input limit exceeded errors (stdin too large, file too large) 46 | */ 47 | export class InputLimitError extends MarkdownAgentError { 48 | constructor(message: string, code: number = 1) { 49 | super(message, code); 50 | this.name = "InputLimitError"; 51 | } 52 | } 53 | 54 | /** 55 | * File not found or inaccessible errors 56 | */ 57 | export class FileNotFoundError extends MarkdownAgentError { 58 | constructor(message: string, code: number = 1) { 59 | super(message, code); 60 | this.name = "FileNotFoundError"; 61 | } 62 | } 63 | 64 | /** 65 | * Network-related errors (failed fetches, connection issues) 66 | */ 67 | export class NetworkError extends MarkdownAgentError { 68 | constructor(message: string, code: number = 1) { 69 | super(message, code); 70 | this.name = "NetworkError"; 71 | } 72 | } 73 | 74 | /** 75 | * Command execution errors (command not found, failed to spawn) 76 | */ 77 | export class CommandError extends MarkdownAgentError { 78 | constructor(message: string, code: number = 1) { 79 | super(message, code); 80 | this.name = "CommandError"; 81 | } 82 | } 83 | 84 | /** 85 | * Command resolution errors (can't determine which command to run) 86 | */ 87 | export class CommandResolutionError extends MarkdownAgentError { 88 | constructor(message: string, code: number = 1) { 89 | super(message, code); 90 | this.name = "CommandResolutionError"; 91 | } 92 | } 93 | 94 | /** 95 | * Import expansion errors (failed to expand @file imports) 96 | */ 97 | export class ImportError extends MarkdownAgentError { 98 | constructor(message: string, code: number = 1) { 99 | super(message, code); 100 | this.name = "ImportError"; 101 | } 102 | } 103 | 104 | /** 105 | * Template processing errors (missing variables, syntax errors) 106 | */ 107 | export class TemplateError extends MarkdownAgentError { 108 | constructor(message: string, code: number = 1) { 109 | super(message, code); 110 | this.name = "TemplateError"; 111 | } 112 | } 113 | 114 | /** 115 | * Hook execution errors (pre/post hook failures) 116 | */ 117 | export class HookError extends MarkdownAgentError { 118 | constructor(message: string, code: number = 1) { 119 | super(message, code); 120 | this.name = "HookError"; 121 | } 122 | } 123 | 124 | /** 125 | * User cancelled the operation (e.g., declined trust prompt) 126 | */ 127 | export class UserCancelledError extends MarkdownAgentError { 128 | constructor(message: string = "Operation cancelled by user", code: number = 1) { 129 | super(message, code); 130 | this.name = "UserCancelledError"; 131 | } 132 | } 133 | 134 | /** 135 | * Early exit request (for --help, --logs, etc. that need clean exit) 136 | * These are not errors per se, but signal that execution should stop with code 0 137 | */ 138 | export class EarlyExitRequest extends MarkdownAgentError { 139 | constructor(message: string = "", code: number = 0) { 140 | super(message, code); 141 | this.name = "EarlyExitRequest"; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Structured logging for mdflow internals 3 | * 4 | * Logs are always written to ~/.mdflow/logs// 5 | * Use `md logs` to show the log directory 6 | * 7 | * Secret Redaction: 8 | * - All log entries are processed to redact sensitive values 9 | * - Keys matching patterns like 'key', 'token', 'secret', 'password', etc. 10 | * have their values replaced with '[REDACTED]' 11 | * - This prevents accidental exposure of secrets in log files 12 | */ 13 | 14 | import pino, { type Logger } from "pino"; 15 | import { homedir } from "os"; 16 | import { mkdirSync, existsSync, readdirSync } from "fs"; 17 | import { join, basename } from "path"; 18 | import { isSensitiveKey, redactArgs } from "./secrets"; 19 | 20 | const LOG_BASE_DIR = join(homedir(), ".mdflow", "logs"); 21 | 22 | // Ensure base log directory exists 23 | if (!existsSync(LOG_BASE_DIR)) { 24 | try { 25 | mkdirSync(LOG_BASE_DIR, { recursive: true }); 26 | } catch { 27 | // Ignore - logging to file is optional 28 | } 29 | } 30 | 31 | /** 32 | * Get the log directory path 33 | */ 34 | export function getLogDir(): string { 35 | return LOG_BASE_DIR; 36 | } 37 | 38 | /** 39 | * Get log file path for a specific agent 40 | */ 41 | export function getAgentLogPath(agentFile: string): string { 42 | const agentName = basename(agentFile, ".md").replace(/\./g, "-"); 43 | return join(LOG_BASE_DIR, agentName, "debug.log"); 44 | } 45 | 46 | /** 47 | * List all agent log directories 48 | */ 49 | export function listLogDirs(): string[] { 50 | try { 51 | if (!existsSync(LOG_BASE_DIR)) return []; 52 | return readdirSync(LOG_BASE_DIR, { withFileTypes: true }) 53 | .filter(d => d.isDirectory()) 54 | .map(d => join(LOG_BASE_DIR, d.name)); 55 | } catch { 56 | return []; 57 | } 58 | } 59 | 60 | // Default silent logger until initialized with agent file 61 | let currentLogger: Logger = pino({ level: "silent" }); 62 | let currentAgentLogPath: string | null = null; 63 | 64 | /** 65 | * Initialize logger for a specific agent file 66 | * Creates a log file at ~/.mdflow/logs//debug.log 67 | */ 68 | export function initLogger(agentFile: string): Logger { 69 | const agentName = basename(agentFile, ".md").replace(/\./g, "-"); 70 | const logDir = join(LOG_BASE_DIR, agentName); 71 | const logFile = join(logDir, "debug.log"); 72 | 73 | // Ensure agent log directory exists 74 | if (!existsSync(logDir)) { 75 | try { 76 | mkdirSync(logDir, { recursive: true }); 77 | } catch { 78 | // Fall back to silent logger 79 | return pino({ level: "silent" }); 80 | } 81 | } 82 | 83 | currentAgentLogPath = logFile; 84 | 85 | // Create logger that writes to file only (no stderr spam) 86 | // Uses a custom serializer to redact sensitive values 87 | currentLogger = pino( 88 | { 89 | level: "debug", 90 | base: { agent: agentName }, 91 | timestamp: pino.stdTimeFunctions.isoTime, 92 | // Custom hooks to redact sensitive data before logging 93 | hooks: { 94 | logMethod(inputArgs, method, level) { 95 | // Process each argument to redact sensitive values 96 | const redactedArgs = inputArgs.map((arg) => { 97 | if (arg && typeof arg === "object" && !Array.isArray(arg)) { 98 | return redactArgs(arg as Record); 99 | } 100 | return arg; 101 | }); 102 | return method.apply(this, redactedArgs as Parameters); 103 | }, 104 | }, 105 | }, 106 | pino.destination({ dest: logFile, sync: false }) 107 | ); 108 | 109 | return currentLogger; 110 | } 111 | 112 | /** 113 | * Get the current logger instance 114 | */ 115 | export function getLogger(): Logger { 116 | return currentLogger; 117 | } 118 | 119 | /** 120 | * Get the current agent's log file path 121 | */ 122 | export function getCurrentLogPath(): string | null { 123 | return currentAgentLogPath; 124 | } 125 | 126 | // Convenience child loggers - these use the current logger 127 | export function getParseLogger(): Logger { 128 | return currentLogger.child({ module: "parse" }); 129 | } 130 | 131 | export function getTemplateLogger(): Logger { 132 | return currentLogger.child({ module: "template" }); 133 | } 134 | 135 | export function getCommandLogger(): Logger { 136 | return currentLogger.child({ module: "command" }); 137 | } 138 | 139 | export function getImportLogger(): Logger { 140 | return currentLogger.child({ module: "import" }); 141 | } 142 | -------------------------------------------------------------------------------- /src/template.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Template variable substitution for markdown content 3 | * Uses LiquidJS for full template support including conditionals and loops 4 | */ 5 | 6 | import { Liquid, analyzeSync } from "liquidjs"; 7 | 8 | export interface TemplateVars { 9 | [key: string]: string; 10 | } 11 | 12 | /** 13 | * Cross-platform shell escaping helper 14 | * Prevents command injection when template variables are used in shell commands 15 | */ 16 | function shellEscape(str: unknown): string { 17 | const s = String(str ?? ""); 18 | if (process.platform === "win32") { 19 | // Windows cmd.exe escaping (double-quote wrapping, escape internal quotes) 20 | return `"${s.replace(/"/g, '""')}"`; 21 | } 22 | // POSIX single quoting (escape single quotes by ending quote, adding escaped quote, starting new quote) 23 | return `'${s.replace(/'/g, "'\\''")}'`; 24 | } 25 | 26 | // Shared Liquid engine instance with lenient settings 27 | const engine = new Liquid({ 28 | strictVariables: false, // Don't throw on undefined variables 29 | strictFilters: false, // Don't throw on undefined filters 30 | }); 31 | 32 | // Register security filters for shell escaping 33 | engine.registerFilter("shell_escape", shellEscape); 34 | engine.registerFilter("q", shellEscape); // Short alias 35 | 36 | /** 37 | * Extract template variables from content using LiquidJS AST parsing 38 | * 39 | * STRICT MODE: Only extracts variables starting with '_' (underscore prefix). 40 | * This prevents {{ model }} in text from stealing the --model CLI flag. 41 | * 42 | * Returns array of global variable names (root segments) found in: 43 | * - {{ _variable }} output patterns 44 | * - {% if _variable %} logic tags 45 | * - {% for item in _collection %} loop tags 46 | * - Variables with filters: {{ _name | upcase }} 47 | * - Nested variables: {{ _user.name }} (returns "_user" as the root) 48 | * 49 | * Uses LiquidJS's analyzeSync for accurate AST-based extraction, 50 | * avoiding regex fragility with complex Liquid syntax. 51 | */ 52 | export function extractTemplateVars(content: string): string[] { 53 | try { 54 | // Parse the template into AST 55 | const templates = engine.parse(content); 56 | // Analyze to find all global variables (undefined in template scope) 57 | const analysis = analyzeSync(templates, { partials: false }); 58 | // Only return variables starting with underscore 59 | // This prevents {{ model }} from consuming --model flags 60 | return Object.keys(analysis.globals).filter((k) => k.startsWith("_")); 61 | } catch { 62 | // Fallback: return empty array if template parsing fails 63 | // This maintains backward compatibility for malformed templates 64 | return []; 65 | } 66 | } 67 | 68 | /** 69 | * Substitute template variables in content using LiquidJS 70 | * Supports: 71 | * - Variable substitution: {{ variable }} 72 | * - Conditionals: {% if condition %}...{% endif %} 73 | * - Loops: {% for item in items %}...{% endfor %} 74 | * - Filters: {{ name | upcase }} 75 | * - Default values: {{ name | default: "World" }} 76 | */ 77 | export function substituteTemplateVars( 78 | content: string, 79 | vars: TemplateVars, 80 | options: { strict?: boolean } = {} 81 | ): string { 82 | const { strict = false } = options; 83 | 84 | if (strict) { 85 | // In strict mode, check for missing variables before rendering 86 | const required = extractTemplateVars(content); 87 | const missing = required.filter((v) => !(v in vars)); 88 | if (missing.length > 0) { 89 | throw new Error(`Missing required template variable: ${missing[0]}`); 90 | } 91 | } 92 | 93 | // Use synchronous renderSync for compatibility 94 | return engine.parseAndRenderSync(content, vars); 95 | } 96 | 97 | /** 98 | * Parse CLI arguments into template variables 99 | * Extracts --key value pairs that aren't known flags 100 | */ 101 | export function parseTemplateArgs( 102 | args: string[], 103 | knownFlags: Set 104 | ): TemplateVars { 105 | const vars: TemplateVars = {}; 106 | 107 | for (let i = 0; i < args.length; i++) { 108 | const arg = args[i]; 109 | const nextArg = args[i + 1]; 110 | 111 | // Skip non-flags 112 | if (!arg?.startsWith("--")) continue; 113 | 114 | const key = arg.slice(2); // Remove -- 115 | 116 | // Skip known flags (handled by CLI parser) 117 | if (knownFlags.has(arg) || knownFlags.has(`--${key}`)) continue; 118 | 119 | // If next arg exists and isn't a flag, it's the value 120 | if (nextArg && !nextArg.startsWith("-")) { 121 | vars[key] = nextArg; 122 | i++; // Skip the value arg 123 | } else { 124 | // Boolean flag without value 125 | vars[key] = "true"; 126 | } 127 | } 128 | 129 | return vars; 130 | } 131 | -------------------------------------------------------------------------------- /src/output-streams.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { 3 | spawnMd, 4 | spawnTestScript, 5 | createTempDir, 6 | createTestAgent, 7 | assertCleanStdout, 8 | PROJECT_ROOT, 9 | } from "./test-utils"; 10 | 11 | /** 12 | * Tests for sanitized output streams 13 | * 14 | * Ensures all system/status messages go to stderr, 15 | * keeping stdout exclusively for agent output. 16 | * This enables clean piping like: git diff | md review.md > review.txt 17 | */ 18 | 19 | describe("Output Stream Separation", () => { 20 | let tempDir: string; 21 | let cleanup: () => Promise; 22 | 23 | beforeAll(async () => { 24 | const temp = await createTempDir("md-streams-test-"); 25 | tempDir = temp.tempDir; 26 | cleanup = temp.cleanup; 27 | }); 28 | 29 | afterAll(async () => { 30 | await cleanup(); 31 | }); 32 | 33 | describe("remote.ts status messages", () => { 34 | test("fetchRemote outputs status to stderr, not stdout", async () => { 35 | const testScript = ` 36 | import { fetchRemote } from "${PROJECT_ROOT}/src/remote"; 37 | const result = await fetchRemote("https://raw.githubusercontent.com/johnlindquist/kit/main/README.md"); 38 | console.log(JSON.stringify({ success: result.success, isRemote: result.isRemote })); 39 | `; 40 | 41 | const result = await spawnTestScript(testScript, tempDir); 42 | 43 | // Stderr should contain status messages 44 | expect(result.stderr).toContain("Fetching:"); 45 | expect(result.stderr).toContain("Saved to:"); 46 | 47 | // Stdout should only contain our JSON result 48 | expect(result.stdout).not.toContain("Fetching:"); 49 | expect(result.stdout).not.toContain("Saved to:"); 50 | 51 | const parsed = JSON.parse(result.stdout.trim()); 52 | expect(parsed.success).toBe(true); 53 | expect(parsed.isRemote).toBe(true); 54 | }); 55 | 56 | test("local files produce no status output", async () => { 57 | const testScript = ` 58 | import { fetchRemote } from "${PROJECT_ROOT}/src/remote"; 59 | const result = await fetchRemote("./src/remote.ts"); 60 | console.log(JSON.stringify({ success: result.success, isRemote: result.isRemote })); 61 | `; 62 | 63 | const result = await spawnTestScript(testScript, tempDir); 64 | 65 | // No status messages for local files 66 | expect(result.stderr).not.toContain("Fetching:"); 67 | expect(result.stderr).not.toContain("Saved to:"); 68 | 69 | const parsed = JSON.parse(result.stdout.trim()); 70 | expect(parsed.success).toBe(true); 71 | expect(parsed.isRemote).toBe(false); 72 | }); 73 | }); 74 | 75 | describe("CLI commands output routing", () => { 76 | test("--help outputs to stdout (requested data)", async () => { 77 | const result = await spawnMd(["--help"]); 78 | expect(result.stdout).toContain("Usage: md"); 79 | expect(result.stdout).toContain("Commands:"); 80 | }); 81 | 82 | test("'logs' subcommand outputs directory info to stdout", async () => { 83 | const result = await spawnMd(["logs"]); 84 | expect(result.stdout).toContain("Log directory:"); 85 | }); 86 | 87 | test("missing file error goes to stderr", async () => { 88 | const result = await spawnMd(["nonexistent-file.md"]); 89 | expect(result.stderr).toContain("File not found"); 90 | expect(result.stdout.trim()).toBe(""); 91 | }); 92 | 93 | test("usage error goes to stderr", async () => { 94 | const result = await spawnMd([]); 95 | expect(result.stderr).toContain("Usage:"); 96 | expect(result.stdout.trim()).toBe(""); 97 | }); 98 | }); 99 | 100 | describe("Plain markdown file output", () => { 101 | test("plain markdown without command pattern outputs error to stderr", async () => { 102 | const mdPath = await createTestAgent( 103 | tempDir, 104 | "plain.md", 105 | "# Hello World\n\nThis is plain markdown." 106 | ); 107 | 108 | const result = await spawnMd([mdPath]); 109 | 110 | expect(result.stderr).toContain("No command specified"); 111 | expect(result.stdout.trim()).toBe(""); 112 | }); 113 | }); 114 | 115 | describe("Piping scenarios", () => { 116 | test("output can be cleanly redirected without status noise", async () => { 117 | const agentPath = await createTestAgent( 118 | tempDir, 119 | "echo.md", 120 | `--- 121 | model: test 122 | --- 123 | Echo test content` 124 | ); 125 | 126 | const result = await spawnMd([agentPath]); 127 | 128 | // Key assertion: no status messages on stdout 129 | assertCleanStdout(result.stdout); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /src/limits.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from "bun:test"; 2 | import { join } from "path"; 3 | import { tmpdir } from "os"; 4 | import { 5 | MAX_INPUT_SIZE, 6 | MAX_INPUT_SIZE_HUMAN, 7 | StdinSizeLimitError, 8 | FileSizeLimitError, 9 | formatBytes, 10 | exceedsLimit, 11 | } from "./limits"; 12 | import { expandImports } from "./imports"; 13 | 14 | describe("limits", () => { 15 | describe("constants", () => { 16 | it("MAX_INPUT_SIZE is 10MB", () => { 17 | expect(MAX_INPUT_SIZE).toBe(10 * 1024 * 1024); 18 | }); 19 | 20 | it("MAX_INPUT_SIZE_HUMAN is readable", () => { 21 | expect(MAX_INPUT_SIZE_HUMAN).toBe("10MB"); 22 | }); 23 | }); 24 | 25 | describe("formatBytes", () => { 26 | it("formats bytes", () => { 27 | expect(formatBytes(500)).toBe("500 bytes"); 28 | }); 29 | 30 | it("formats kilobytes", () => { 31 | expect(formatBytes(2048)).toBe("2.0KB"); 32 | }); 33 | 34 | it("formats megabytes", () => { 35 | expect(formatBytes(5 * 1024 * 1024)).toBe("5.0MB"); 36 | }); 37 | 38 | it("formats decimal megabytes", () => { 39 | expect(formatBytes(10.5 * 1024 * 1024)).toBe("10.5MB"); 40 | }); 41 | }); 42 | 43 | describe("exceedsLimit", () => { 44 | it("returns false for small sizes", () => { 45 | expect(exceedsLimit(1000)).toBe(false); 46 | }); 47 | 48 | it("returns false at exactly the limit", () => { 49 | expect(exceedsLimit(MAX_INPUT_SIZE)).toBe(false); 50 | }); 51 | 52 | it("returns true above the limit", () => { 53 | expect(exceedsLimit(MAX_INPUT_SIZE + 1)).toBe(true); 54 | }); 55 | }); 56 | 57 | describe("StdinSizeLimitError", () => { 58 | it("has descriptive error message", () => { 59 | const error = new StdinSizeLimitError(15 * 1024 * 1024); 60 | expect(error.name).toBe("StdinSizeLimitError"); 61 | expect(error.message).toContain("Input exceeds 10MB limit"); 62 | expect(error.message).toContain("15.0MB"); 63 | expect(error.message).toContain("Use a file path argument instead of piping"); 64 | }); 65 | }); 66 | 67 | describe("FileSizeLimitError", () => { 68 | it("has descriptive error message with file path", () => { 69 | const error = new FileSizeLimitError("/path/to/large.log", 20 * 1024 * 1024); 70 | expect(error.name).toBe("FileSizeLimitError"); 71 | expect(error.message).toContain("/path/to/large.log"); 72 | expect(error.message).toContain("exceeds 10MB limit"); 73 | expect(error.message).toContain("20.0MB"); 74 | expect(error.message).toContain("line ranges"); 75 | expect(error.message).toContain("symbol extraction"); 76 | }); 77 | }); 78 | }); 79 | 80 | describe("file import size limits", () => { 81 | const testDir = join(tmpdir(), `md-limits-test-${Date.now()}`); 82 | const smallFilePath = join(testDir, "small.txt"); 83 | const largeFilePath = join(testDir, "large.txt"); 84 | 85 | beforeAll(async () => { 86 | // Create test directory 87 | await Bun.write(join(testDir, ".gitkeep"), ""); 88 | 89 | // Create a small file (100 bytes) 90 | await Bun.write(smallFilePath, "x".repeat(100)); 91 | 92 | // Create a file just over the limit (10MB + 1KB) 93 | const largeContent = "x".repeat(MAX_INPUT_SIZE + 1024); 94 | await Bun.write(largeFilePath, largeContent); 95 | }); 96 | 97 | afterAll(async () => { 98 | // Clean up test files 99 | const fs = await import("fs/promises"); 100 | await fs.rm(testDir, { recursive: true, force: true }); 101 | }); 102 | 103 | it("allows importing small files", async () => { 104 | const content = `@${smallFilePath}`; 105 | const result = await expandImports(content, testDir); 106 | expect(result).toBe("x".repeat(100)); 107 | }); 108 | 109 | it("throws FileSizeLimitError for files exceeding limit", async () => { 110 | const content = `@${largeFilePath}`; 111 | 112 | try { 113 | await expandImports(content, testDir); 114 | expect(true).toBe(false); // Should not reach here 115 | } catch (error) { 116 | expect(error).toBeInstanceOf(FileSizeLimitError); 117 | expect((error as FileSizeLimitError).message).toContain(largeFilePath); 118 | expect((error as FileSizeLimitError).message).toContain("exceeds 10MB limit"); 119 | } 120 | }); 121 | 122 | it("provides helpful suggestions in error message", async () => { 123 | const content = `@${largeFilePath}`; 124 | 125 | try { 126 | await expandImports(content, testDir); 127 | expect(true).toBe(false); // Should not reach here 128 | } catch (error) { 129 | const message = (error as Error).message; 130 | // Error should suggest alternatives 131 | expect(message).toContain("line ranges"); 132 | expect(message).toContain("symbol extraction"); 133 | } 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from "bun:test"; 2 | import { validateFrontmatter, safeParseFrontmatter, validateConfig, safeParseConfig } from "./schema"; 3 | 4 | describe("validateFrontmatter", () => { 5 | test("validates empty frontmatter", () => { 6 | const result = validateFrontmatter({}); 7 | expect(result).toEqual({}); 8 | }); 9 | 10 | test("validates _inputs array", () => { 11 | const result = validateFrontmatter({ 12 | _inputs: ["message", "branch"] 13 | }); 14 | expect(result._inputs).toEqual(["message", "branch"]); 15 | }); 16 | 17 | test("validates env as object (process.env config)", () => { 18 | const result = validateFrontmatter({ 19 | env: { HOST: "localhost", PORT: "3000" } 20 | }); 21 | expect(result.env).toEqual({ HOST: "localhost", PORT: "3000" }); 22 | }); 23 | 24 | test("validates env as array (--env flags)", () => { 25 | const result = validateFrontmatter({ 26 | env: ["HOST=localhost", "PORT=3000"] 27 | }); 28 | expect(result.env).toEqual(["HOST=localhost", "PORT=3000"]); 29 | }); 30 | 31 | test("validates env as string", () => { 32 | const result = validateFrontmatter({ 33 | env: "HOST=localhost" 34 | }); 35 | expect(result.env).toBe("HOST=localhost"); 36 | }); 37 | 38 | test("allows $N positional mappings", () => { 39 | const result = validateFrontmatter({ 40 | $1: "prompt", 41 | $2: "model" 42 | }); 43 | expect((result as any).$1).toBe("prompt"); 44 | expect((result as any).$2).toBe("model"); 45 | }); 46 | 47 | test("allows unknown keys - they become CLI flags", () => { 48 | const result = validateFrontmatter({ 49 | model: "opus", 50 | "dangerously-skip-permissions": true, 51 | "mcp-config": "./mcp.json" 52 | }); 53 | expect((result as any).model).toBe("opus"); 54 | expect((result as any)["dangerously-skip-permissions"]).toBe(true); 55 | expect((result as any)["mcp-config"]).toBe("./mcp.json"); 56 | }); 57 | }); 58 | 59 | describe("safeParseFrontmatter", () => { 60 | test("returns success with valid data", () => { 61 | const result = safeParseFrontmatter({ model: "opus" }); 62 | expect(result.success).toBe(true); 63 | expect(result.data?.model).toBe("opus"); 64 | }); 65 | 66 | test("returns success with _inputs", () => { 67 | const result = safeParseFrontmatter({ _inputs: ["name", "value"] }); 68 | expect(result.success).toBe(true); 69 | expect(result.data?._inputs).toEqual(["name", "value"]); 70 | }); 71 | 72 | test("returns errors when _inputs is not an array", () => { 73 | const result = safeParseFrontmatter({ _inputs: "invalid" }); 74 | expect(result.success).toBe(false); 75 | expect(result.errors).toBeDefined(); 76 | }); 77 | }); 78 | 79 | describe("validateConfig", () => { 80 | test("validates empty config", () => { 81 | const result = validateConfig({}); 82 | expect(result).toEqual({}); 83 | }); 84 | 85 | test("validates config with commands", () => { 86 | const result = validateConfig({ 87 | commands: { 88 | claude: { model: "opus", print: true }, 89 | gemini: { model: "pro" } 90 | } 91 | }); 92 | expect(result.commands?.claude?.model).toBe("opus"); 93 | expect(result.commands?.claude?.print).toBe(true); 94 | expect(result.commands?.gemini?.model).toBe("pro"); 95 | }); 96 | 97 | test("validates config with positional mappings", () => { 98 | const result = validateConfig({ 99 | commands: { 100 | copilot: { $1: "prompt" } 101 | } 102 | }); 103 | expect(result.commands?.copilot?.["$1"]).toBe("prompt"); 104 | }); 105 | 106 | test("validates config with array values", () => { 107 | const result = validateConfig({ 108 | commands: { 109 | claude: { "add-dir": ["./src", "./tests"] } 110 | } 111 | }); 112 | expect(result.commands?.claude?.["add-dir"]).toEqual(["./src", "./tests"]); 113 | }); 114 | 115 | test("throws on invalid config with unknown top-level keys", () => { 116 | expect(() => validateConfig({ 117 | commands: {}, 118 | invalidKey: "value" 119 | })).toThrow("Invalid config.yaml"); 120 | }); 121 | }); 122 | 123 | describe("safeParseConfig", () => { 124 | test("returns success with valid config", () => { 125 | const result = safeParseConfig({ 126 | commands: { claude: { model: "opus" } } 127 | }); 128 | expect(result.success).toBe(true); 129 | expect(result.data?.commands?.claude?.model).toBe("opus"); 130 | }); 131 | 132 | test("returns errors for invalid config", () => { 133 | const result = safeParseConfig({ 134 | commands: {}, 135 | unknownField: true 136 | }); 137 | expect(result.success).toBe(false); 138 | expect(result.errors).toBeDefined(); 139 | expect(result.errors!.length).toBeGreaterThan(0); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/parse.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from "bun:test"; 2 | import { parseFrontmatter, stripShebang } from "./parse"; 3 | 4 | describe("parseFrontmatter", () => { 5 | test("returns empty frontmatter when no frontmatter present", () => { 6 | const content = "Just some content"; 7 | const result = parseFrontmatter(content); 8 | expect(result.frontmatter).toEqual({}); 9 | expect(result.body).toBe("Just some content"); 10 | }); 11 | 12 | test("parses simple string values", () => { 13 | const content = `--- 14 | model: claude-haiku-4.5 15 | agent: my-agent 16 | --- 17 | Body content`; 18 | const result = parseFrontmatter(content); 19 | expect(result.frontmatter.model).toBe("claude-haiku-4.5"); 20 | expect(result.frontmatter.agent).toBe("my-agent"); 21 | expect(result.body).toBe("Body content"); 22 | }); 23 | 24 | test("parses boolean values", () => { 25 | const content = `--- 26 | silent: true 27 | interactive: false 28 | --- 29 | Body`; 30 | const result = parseFrontmatter(content); 31 | expect(result.frontmatter.silent).toBe(true); 32 | expect(result.frontmatter.interactive).toBe(false); 33 | }); 34 | 35 | test("parses inline array", () => { 36 | const content = `--- 37 | context: ["src/**/*.ts", "tests/**/*.ts"] 38 | --- 39 | Body`; 40 | const result = parseFrontmatter(content); 41 | expect(result.frontmatter.context).toEqual(["src/**/*.ts", "tests/**/*.ts"]); 42 | }); 43 | 44 | test("parses multiline array", () => { 45 | const content = `--- 46 | context: 47 | - src/**/*.ts 48 | - lib/**/*.ts 49 | model: gpt-5 50 | --- 51 | Body`; 52 | const result = parseFrontmatter(content); 53 | expect(result.frontmatter.context).toEqual(["src/**/*.ts", "lib/**/*.ts"]); 54 | expect(result.frontmatter.model).toBe("gpt-5"); 55 | }); 56 | 57 | test("handles kebab-case keys", () => { 58 | const content = `--- 59 | allow-all-tools: true 60 | allow-tool: shell(git:*) 61 | deny-tool: shell(rm) 62 | add-dir: /tmp 63 | --- 64 | Body`; 65 | const result = parseFrontmatter(content); 66 | expect(result.frontmatter["allow-all-tools"]).toBe(true); 67 | expect(result.frontmatter["allow-tool"]).toBe("shell(git:*)"); 68 | expect(result.frontmatter["deny-tool"]).toBe("shell(rm)"); 69 | expect(result.frontmatter["add-dir"]).toBe("/tmp"); 70 | }); 71 | 72 | test("preserves multiline body", () => { 73 | const content = `--- 74 | model: gpt-5 75 | --- 76 | 77 | Line 1 78 | 79 | Line 2 80 | 81 | Line 3`; 82 | const result = parseFrontmatter(content); 83 | expect(result.body).toBe("Line 1\n\nLine 2\n\nLine 3"); 84 | }); 85 | 86 | test("strips shebang line before parsing", () => { 87 | const content = `#!/usr/bin/env md 88 | --- 89 | model: gpt-5 90 | --- 91 | Body content`; 92 | const result = parseFrontmatter(content); 93 | expect(result.frontmatter.model).toBe("gpt-5"); 94 | expect(result.body).toBe("Body content"); 95 | }); 96 | 97 | test("handles shebang without frontmatter", () => { 98 | const content = `#!/usr/bin/env md 99 | Just some content`; 100 | const result = parseFrontmatter(content); 101 | expect(result.frontmatter).toEqual({}); 102 | expect(result.body).toBe("Just some content"); 103 | }); 104 | }); 105 | 106 | describe("stripShebang", () => { 107 | test("removes shebang line", () => { 108 | const content = `#!/usr/bin/env md 109 | rest of content`; 110 | expect(stripShebang(content)).toBe("rest of content"); 111 | }); 112 | 113 | test("preserves content without shebang", () => { 114 | const content = "no shebang here"; 115 | expect(stripShebang(content)).toBe("no shebang here"); 116 | }); 117 | 118 | test("handles various shebang formats", () => { 119 | expect(stripShebang("#!/bin/bash\nrest")).toBe("rest"); 120 | expect(stripShebang("#! /usr/bin/env node\nrest")).toBe("rest"); 121 | expect(stripShebang("#!/usr/local/bin/md\nrest")).toBe("rest"); 122 | }); 123 | }); 124 | 125 | describe("parseFrontmatter passthrough", () => { 126 | test("passes through arbitrary nested objects", () => { 127 | const content = `--- 128 | custom: 129 | nested: 130 | value: 42 131 | model: gpt-5 132 | --- 133 | Body`; 134 | const result = parseFrontmatter(content); 135 | expect(result.frontmatter.custom).toEqual({ nested: { value: 42 } }); 136 | expect(result.frontmatter.model).toBe("gpt-5"); 137 | }); 138 | 139 | test("passes through arrays of objects", () => { 140 | const content = `--- 141 | items: 142 | - name: first 143 | value: 1 144 | - name: second 145 | value: 2 146 | --- 147 | Body`; 148 | const result = parseFrontmatter(content); 149 | const items = result.frontmatter.items as Array<{ name: string; value: number }>; 150 | expect(items).toHaveLength(2); 151 | expect(items[0]).toEqual({ name: "first", value: 1 }); 152 | expect(items[1]).toEqual({ name: "second", value: 2 }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /src/edit-prompt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Edit-before-execute functionality 3 | * 4 | * Opens the fully resolved prompt in the user's $EDITOR before execution, 5 | * allowing last-minute tweaks to the prompt. 6 | */ 7 | 8 | import { tmpdir } from "os"; 9 | import { join } from "path"; 10 | import { writeFile, readFile, unlink } from "fs/promises"; 11 | 12 | /** 13 | * Result of the edit-before-execute flow 14 | */ 15 | export interface EditPromptResult { 16 | /** The modified prompt content, or null if cancelled */ 17 | prompt: string | null; 18 | /** Whether the user confirmed execution */ 19 | confirmed: boolean; 20 | } 21 | 22 | /** 23 | * Get the editor command to use 24 | * Priority: $EDITOR > $VISUAL > vim > nano 25 | */ 26 | export function getEditor(): string { 27 | const editor = process.env.EDITOR || process.env.VISUAL; 28 | if (editor) return editor; 29 | 30 | // Fallback to common editors 31 | // Check if vim exists 32 | if (Bun.which("vim")) return "vim"; 33 | if (Bun.which("nano")) return "nano"; 34 | if (Bun.which("vi")) return "vi"; 35 | 36 | // Last resort 37 | return "vim"; 38 | } 39 | 40 | /** 41 | * Generate a unique temp file path for editing 42 | */ 43 | export function getTempFilePath(prefix: string = "mdflow-edit"): string { 44 | const timestamp = Date.now(); 45 | const random = Math.random().toString(36).substring(2, 8); 46 | return join(tmpdir(), `${prefix}-${timestamp}-${random}.md`); 47 | } 48 | 49 | /** 50 | * Open a file in the user's editor and wait for it to close 51 | */ 52 | export async function openInEditor(filePath: string): Promise { 53 | const editor = getEditor(); 54 | 55 | // Parse editor command (might include args like "code --wait") 56 | const parts = editor.split(/\s+/); 57 | const cmd = parts[0]; 58 | const args = [...parts.slice(1), filePath]; 59 | 60 | if (!cmd) { 61 | console.error("No editor command found"); 62 | return false; 63 | } 64 | 65 | const proc = Bun.spawn([cmd, ...args], { 66 | stdin: "inherit", 67 | stdout: "inherit", 68 | stderr: "inherit", 69 | }); 70 | 71 | const exitCode = await proc.exited; 72 | return exitCode === 0; 73 | } 74 | 75 | /** 76 | * Prompt the user to confirm running the modified prompt 77 | */ 78 | export async function confirmExecution( 79 | promptFn?: () => Promise 80 | ): Promise { 81 | // If a custom prompt function is provided (for testing), use it 82 | if (promptFn) { 83 | const answer = await promptFn(); 84 | return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"; 85 | } 86 | 87 | // Use readline for simple y/N prompt 88 | const readline = await import("readline"); 89 | const rl = readline.createInterface({ 90 | input: process.stdin, 91 | output: process.stdout, 92 | }); 93 | 94 | return new Promise((resolve) => { 95 | rl.question("Run this modified prompt? [y/N] ", (answer) => { 96 | rl.close(); 97 | resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); 98 | }); 99 | }); 100 | } 101 | 102 | /** 103 | * Edit a prompt before execution 104 | * 105 | * 1. Writes the resolved prompt to a temp file with .md extension 106 | * 2. Opens the file in $EDITOR (or vim/nano as fallback) 107 | * 3. Waits for editor to close 108 | * 4. Reads the modified content 109 | * 5. Prompts user: "Run this modified prompt? [y/N]" 110 | * 6. Returns the modified prompt or null if cancelled 111 | * 112 | * @param prompt The resolved prompt to edit 113 | * @param options Options for the edit flow 114 | * @returns The modified prompt and confirmation status 115 | */ 116 | export async function editPrompt( 117 | prompt: string, 118 | options: { 119 | /** Custom confirm function for testing */ 120 | confirmFn?: () => Promise; 121 | /** Skip confirmation prompt (auto-confirm) */ 122 | skipConfirm?: boolean; 123 | } = {} 124 | ): Promise { 125 | const tempPath = getTempFilePath(); 126 | 127 | try { 128 | // Write prompt to temp file 129 | await writeFile(tempPath, prompt, "utf-8"); 130 | 131 | // Open in editor 132 | const editorSuccess = await openInEditor(tempPath); 133 | if (!editorSuccess) { 134 | console.error("Editor exited with non-zero status"); 135 | return { prompt: null, confirmed: false }; 136 | } 137 | 138 | // Read modified content 139 | const modifiedPrompt = await readFile(tempPath, "utf-8"); 140 | 141 | // Confirm execution 142 | let confirmed: boolean; 143 | if (options.skipConfirm) { 144 | confirmed = true; 145 | } else { 146 | confirmed = await confirmExecution(options.confirmFn); 147 | } 148 | 149 | if (!confirmed) { 150 | console.log("Execution cancelled."); 151 | return { prompt: null, confirmed: false }; 152 | } 153 | 154 | return { prompt: modifiedPrompt, confirmed: true }; 155 | } finally { 156 | // Clean up temp file 157 | try { 158 | await unlink(tempPath); 159 | } catch { 160 | // Ignore cleanup errors 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/remote.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeEach, afterEach } from "bun:test"; 2 | import { 3 | isRemoteUrl, 4 | toRawUrl, 5 | fetchRemote, 6 | type FetchRemoteOptions, 7 | } from "./remote"; 8 | import { clearAllCache, getCachedContent } from "./cache"; 9 | 10 | describe("isRemoteUrl", () => { 11 | test("returns true for http URL", () => { 12 | expect(isRemoteUrl("http://example.com/file.md")).toBe(true); 13 | }); 14 | 15 | test("returns true for https URL", () => { 16 | expect(isRemoteUrl("https://example.com/file.md")).toBe(true); 17 | }); 18 | 19 | test("returns false for local path", () => { 20 | expect(isRemoteUrl("./DEMO.md")).toBe(false); 21 | }); 22 | 23 | test("returns false for absolute path", () => { 24 | expect(isRemoteUrl("/home/user/file.md")).toBe(false); 25 | }); 26 | 27 | test("returns false for relative path", () => { 28 | expect(isRemoteUrl("instructions/DEMO.md")).toBe(false); 29 | }); 30 | }); 31 | 32 | describe("toRawUrl", () => { 33 | test("converts GitHub Gist URL to raw", () => { 34 | const url = "https://gist.github.com/user/abc123def456"; 35 | const raw = toRawUrl(url); 36 | expect(raw).toBe("https://gist.githubusercontent.com/user/abc123def456/raw"); 37 | }); 38 | 39 | test("converts GitHub blob URL to raw", () => { 40 | const url = "https://github.com/user/repo/blob/main/scripts/deploy.md"; 41 | const raw = toRawUrl(url); 42 | expect(raw).toBe("https://raw.githubusercontent.com/user/repo/main/scripts/deploy.md"); 43 | }); 44 | 45 | test("converts GitLab blob URL to raw", () => { 46 | const url = "https://gitlab.com/user/repo/-/blob/main/file.md"; 47 | const raw = toRawUrl(url); 48 | expect(raw).toBe("https://gitlab.com/user/repo/-/raw/main/file.md"); 49 | }); 50 | 51 | test("returns unchanged URL for already raw content", () => { 52 | const url = "https://raw.githubusercontent.com/user/repo/main/file.md"; 53 | const raw = toRawUrl(url); 54 | expect(raw).toBe(url); 55 | }); 56 | 57 | test("returns unchanged URL for unknown sources", () => { 58 | const url = "https://example.com/file.md"; 59 | const raw = toRawUrl(url); 60 | expect(raw).toBe(url); 61 | }); 62 | }); 63 | 64 | describe("fetchRemote", () => { 65 | test("returns isRemote: false for local paths", async () => { 66 | const result = await fetchRemote("./local/path.md"); 67 | expect(result.isRemote).toBe(false); 68 | expect(result.success).toBe(true); 69 | expect(result.localPath).toBe("./local/path.md"); 70 | }); 71 | 72 | test("returns isRemote: true for http URLs", async () => { 73 | // Use a URL that will fail (no network call needed for this test) 74 | const result = await fetchRemote("http://nonexistent.invalid/file.md"); 75 | expect(result.isRemote).toBe(true); 76 | }); 77 | 78 | test("returns isRemote: true for https URLs", async () => { 79 | // Use a URL that will fail (no network call needed for this test) 80 | const result = await fetchRemote("https://nonexistent.invalid/file.md"); 81 | expect(result.isRemote).toBe(true); 82 | }); 83 | }); 84 | 85 | describe("fetchRemote caching", () => { 86 | // Clean up cache before and after tests 87 | beforeEach(async () => { 88 | await clearAllCache(); 89 | }); 90 | 91 | afterEach(async () => { 92 | await clearAllCache(); 93 | }); 94 | 95 | test("caches fetched content", async () => { 96 | // Fetch a small public file 97 | const url = "https://jsonplaceholder.typicode.com/posts/1"; 98 | const result = await fetchRemote(url); 99 | 100 | // Should succeed (might fail if network is down, but that's OK for this test) 101 | if (result.success) { 102 | expect(result.fromCache).toBe(false); 103 | 104 | // Check that content was cached 105 | const cached = await getCachedContent(toRawUrl(url)); 106 | expect(cached.hit).toBe(true); 107 | } 108 | }); 109 | 110 | test("returns cached content on second fetch", async () => { 111 | const url = "https://jsonplaceholder.typicode.com/posts/1"; 112 | 113 | // First fetch - should not be from cache 114 | const result1 = await fetchRemote(url); 115 | if (!result1.success) { 116 | // Skip test if network is unavailable 117 | return; 118 | } 119 | expect(result1.fromCache).toBe(false); 120 | 121 | // Second fetch - should be from cache 122 | const result2 = await fetchRemote(url); 123 | expect(result2.success).toBe(true); 124 | expect(result2.fromCache).toBe(true); 125 | }); 126 | 127 | test("bypasses cache with noCache option", async () => { 128 | const url = "https://jsonplaceholder.typicode.com/posts/1"; 129 | 130 | // First fetch - populate cache 131 | const result1 = await fetchRemote(url); 132 | if (!result1.success) { 133 | // Skip test if network is unavailable 134 | return; 135 | } 136 | 137 | // Second fetch with noCache - should not use cache 138 | const result2 = await fetchRemote(url, { noCache: true }); 139 | expect(result2.success).toBe(true); 140 | expect(result2.fromCache).toBe(false); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /src/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeEach, afterEach } from "bun:test"; 2 | import { parseCliArgs, findAgentFiles, getProjectAgentsDir, getUserAgentsDir } from "./cli"; 3 | import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; 4 | import { join } from "path"; 5 | import { homedir } from "os"; 6 | 7 | describe("parseCliArgs", () => { 8 | test("extracts file path", () => { 9 | const result = parseCliArgs(["node", "script", "DEMO.md"]); 10 | expect(result.filePath).toBe("DEMO.md"); 11 | expect(result.passthroughArgs).toEqual([]); 12 | }); 13 | 14 | test("all flags pass through when file is provided", () => { 15 | const result = parseCliArgs([ 16 | "node", "script", "DEMO.md", 17 | "-p", "print mode", 18 | "--model", "opus", 19 | "--verbose" 20 | ]); 21 | expect(result.filePath).toBe("DEMO.md"); 22 | expect(result.passthroughArgs).toEqual(["-p", "print mode", "--model", "opus", "--verbose"]); 23 | }); 24 | 25 | test("--help works when no file provided", () => { 26 | const result = parseCliArgs(["node", "script", "--help"]); 27 | expect(result.filePath).toBe(""); 28 | expect(result.help).toBe(true); 29 | }); 30 | 31 | test("subcommands are treated as filePath (handled by index.ts)", () => { 32 | // setup, logs, create are subcommands - they appear as filePath 33 | // and are intercepted by index.ts before being treated as files 34 | const setupResult = parseCliArgs(["node", "script", "setup"]); 35 | expect(setupResult.filePath).toBe("setup"); 36 | 37 | const logsResult = parseCliArgs(["node", "script", "logs"]); 38 | expect(logsResult.filePath).toBe("logs"); 39 | 40 | const createResult = parseCliArgs(["node", "script", "create", "-g"]); 41 | expect(createResult.filePath).toBe("create"); 42 | expect(createResult.passthroughArgs).toEqual(["-g"]); 43 | }); 44 | 45 | test("md flags pass through when file is provided", () => { 46 | const result = parseCliArgs(["node", "script", "DEMO.md", "--help", "--model", "opus"]); 47 | expect(result.filePath).toBe("DEMO.md"); 48 | expect(result.help).toBe(false); 49 | expect(result.passthroughArgs).toEqual(["--help", "--model", "opus"]); 50 | }); 51 | }); 52 | 53 | describe("agent directory paths", () => { 54 | test("getProjectAgentsDir returns .mdflow in cwd", () => { 55 | const dir = getProjectAgentsDir(); 56 | expect(dir).toBe(join(process.cwd(), ".mdflow")); 57 | }); 58 | 59 | test("getUserAgentsDir returns ~/.mdflow", () => { 60 | const dir = getUserAgentsDir(); 61 | expect(dir).toBe(join(homedir(), ".mdflow")); 62 | }); 63 | }); 64 | 65 | describe("findAgentFiles", () => { 66 | const testProjectDir = join(process.cwd(), ".mdflow-test"); 67 | const testUserDir = join(homedir(), ".mdflow-test-user"); 68 | 69 | beforeEach(() => { 70 | // Create test directories 71 | if (!existsSync(testProjectDir)) { 72 | mkdirSync(testProjectDir, { recursive: true }); 73 | } 74 | }); 75 | 76 | afterEach(() => { 77 | // Cleanup test directories 78 | if (existsSync(testProjectDir)) { 79 | rmSync(testProjectDir, { recursive: true, force: true }); 80 | } 81 | }); 82 | 83 | test("finds files from current directory", async () => { 84 | const files = await findAgentFiles(); 85 | // Should find .md files in cwd (like CLAUDE.md, README.md, etc.) 86 | const cwdFiles = files.filter(f => f.source === "cwd"); 87 | expect(cwdFiles.length).toBeGreaterThan(0); 88 | }); 89 | 90 | test("finds files from .mdflow/ directory when present", async () => { 91 | // Create a test .mdflow directory with a file 92 | const mdflowDir = join(process.cwd(), ".mdflow"); 93 | const testFile = join(mdflowDir, "test-agent.claude.md"); 94 | 95 | try { 96 | mkdirSync(mdflowDir, { recursive: true }); 97 | writeFileSync(testFile, "---\nmodel: opus\n---\nTest agent"); 98 | 99 | const files = await findAgentFiles(); 100 | const mdflowFiles = files.filter(f => f.source === ".mdflow"); 101 | 102 | expect(mdflowFiles.length).toBeGreaterThan(0); 103 | expect(mdflowFiles.some(f => f.name === "test-agent.claude.md")).toBe(true); 104 | } finally { 105 | // Cleanup 106 | if (existsSync(testFile)) rmSync(testFile); 107 | if (existsSync(mdflowDir)) rmSync(mdflowDir, { recursive: true, force: true }); 108 | } 109 | }); 110 | 111 | test("deduplicates files by normalized path", async () => { 112 | const files = await findAgentFiles(); 113 | const paths = files.map(f => f.path); 114 | const uniquePaths = new Set(paths); 115 | expect(paths.length).toBe(uniquePaths.size); 116 | }); 117 | 118 | test("returns files with correct structure", async () => { 119 | const files = await findAgentFiles(); 120 | if (files.length > 0) { 121 | const file = files[0]!; 122 | expect(file).toHaveProperty("name"); 123 | expect(file).toHaveProperty("path"); 124 | expect(file).toHaveProperty("source"); 125 | expect(typeof file.name).toBe("string"); 126 | expect(typeof file.path).toBe("string"); 127 | expect(typeof file.source).toBe("string"); 128 | } 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/__snapshots__/snapshot.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Bun Snapshot v1, https://bun.sh/docs/test/snapshots 2 | 3 | exports[`Snapshot Tests Simple markdown (no imports) preserves simple markdown content 1`] = ` 4 | "This is a simple markdown file with no imports. 5 | 6 | It has multiple paragraphs and should be preserved exactly. 7 | 8 | - Bullet point 1 9 | - Bullet point 2 10 | - Bullet point 3" 11 | `; 12 | 13 | exports[`Snapshot Tests File imports expands single file import 1`] = ` 14 | "Here is imported content: 15 | 16 | This is helper content that will be imported. 17 | 18 | 19 | End of prompt." 20 | `; 21 | 22 | exports[`Snapshot Tests File imports expands nested imports recursively 1`] = ` 23 | "Start of nested import test. 24 | 25 | Outer content starts here. 26 | 27 | ** Inner nested content ** 28 | 29 | 30 | Outer content ends here. 31 | 32 | 33 | End of nested import test." 34 | `; 35 | 36 | exports[`Snapshot Tests Line range imports extracts specific line ranges 1`] = ` 37 | "Extracting lines 3-7 from the lines file: 38 | 39 | Line 3: Getting Started 40 | Line 4: Installation 41 | Line 5: Configuration 42 | Line 6: Basic Usage 43 | Line 7: Advanced Features 44 | 45 | That's the relevant section." 46 | `; 47 | 48 | exports[`Snapshot Tests Symbol extraction extracts TypeScript symbols 1`] = ` 49 | "Here is the UserProfile interface: 50 | 51 | export interface UserProfile { 52 | id: number; 53 | name: string; 54 | email: string; 55 | createdAt: Date; 56 | } 57 | 58 | And here is the formatUser function: 59 | 60 | export function formatUser(user: UserProfile): string { 61 | return \`\${user.name} <\${user.email}>\`; 62 | } 63 | 64 | Use these to implement the feature." 65 | `; 66 | 67 | exports[`Snapshot Tests Glob imports expands glob patterns to XML format 1`] = ` 68 | "Here are all TypeScript files in the glob-test directory: 69 | 70 | 71 | // File a.ts 72 | export const A = "alpha"; 73 | 74 | 75 | 76 | 77 | // File b.ts 78 | export const B = "beta"; 79 | 80 | 81 | 82 | Process these files accordingly." 83 | `; 84 | 85 | exports[`Snapshot Tests Command substitution executes and inlines command output 1`] = ` 86 | "Current date context: 87 | 88 | Test command output 89 | 90 | Please proceed with the task." 91 | `; 92 | 93 | exports[`Snapshot Tests Template variables substitutes template variables 1`] = ` 94 | "Please refactor the file at src/main.ts. 95 | 96 | 97 | 98 | 99 | Apply changes directly to the files. 100 | " 101 | `; 102 | 103 | exports[`Snapshot Tests Template variables handles conditional blocks (verbose enabled) 1`] = ` 104 | "Please analyze the file at src/main.ts. 105 | 106 | 107 | Be verbose in your output and explain each step. 108 | 109 | 110 | 111 | Apply changes directly to the files. 112 | " 113 | `; 114 | 115 | exports[`Snapshot Tests Template variables handles conditional blocks (dry_run enabled) 1`] = ` 116 | "Please update the file at src/main.ts. 117 | 118 | 119 | 120 | 121 | This is a dry run - do not make actual changes. 122 | " 123 | `; 124 | 125 | exports[`Snapshot Tests Complex combined scenarios handles multiple import types with templates 1`] = ` 126 | "# Feature Implementation: UserAuthentication 127 | 128 | ## Context 129 | 130 | This document describes the implementation of the UserAuthentication feature. 131 | 132 | ### Type Definitions 133 | 134 | export interface UserProfile { 135 | id: number; 136 | name: string; 137 | email: string; 138 | createdAt: Date; 139 | } 140 | 141 | ### Configuration 142 | 143 | export const DEFAULT_CONFIG = { 144 | apiUrl: "https://api.example.com", 145 | timeout: 5000, 146 | retries: 3, 147 | }; 148 | 149 | ### Helper Content 150 | 151 | This is helper content that will be imported. 152 | 153 | 154 | ## Requirements 155 | 156 | 157 | Running in lenient mode - some validations may be skipped. 158 | 159 | 160 | ## Reference Lines 161 | 162 | Here are lines 4-6 from the reference doc: 163 | 164 | Line 4: Installation 165 | Line 5: Configuration 166 | Line 6: Basic Usage 167 | 168 | ## Instructions 169 | 170 | Please implement the UserAuthentication feature following these guidelines." 171 | `; 172 | 173 | exports[`Snapshot Tests Complex combined scenarios handles multiple import types with strict mode 1`] = ` 174 | "# Feature Implementation: DataValidation 175 | 176 | ## Context 177 | 178 | This document describes the implementation of the DataValidation feature. 179 | 180 | ### Type Definitions 181 | 182 | export interface UserProfile { 183 | id: number; 184 | name: string; 185 | email: string; 186 | createdAt: Date; 187 | } 188 | 189 | ### Configuration 190 | 191 | export const DEFAULT_CONFIG = { 192 | apiUrl: "https://api.example.com", 193 | timeout: 5000, 194 | retries: 3, 195 | }; 196 | 197 | ### Helper Content 198 | 199 | This is helper content that will be imported. 200 | 201 | 202 | ## Requirements 203 | 204 | 205 | Running in strict mode - all validations will be enforced. 206 | 207 | 208 | ## Reference Lines 209 | 210 | Here are lines 4-6 from the reference doc: 211 | 212 | Line 4: Installation 213 | Line 5: Configuration 214 | Line 6: Basic Usage 215 | 216 | ## Instructions 217 | 218 | Please implement the DataValidation feature following these guidelines." 219 | `; 220 | 221 | exports[`URL imports (mocked) fetches and inlines markdown URL 1`] = ` 222 | "Documentation: 223 | 224 | # Documentation 225 | 226 | This is mocked documentation content. 227 | 228 | End of docs." 229 | `; 230 | 231 | exports[`URL imports (mocked) fetches and inlines JSON URL 1`] = ` 232 | "Config: 233 | 234 | {"version":"1.0.0","features":["auth","cache"]} 235 | 236 | Use this config." 237 | `; 238 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Environment variable loading using Bun's native .env support 3 | * 4 | * Bun automatically loads .env files from the current working directory. 5 | * This module extends that to also load from the markdown file's directory. 6 | * 7 | * Loading order (later files override earlier): 8 | * 1. .env (base environment) 9 | * 2. .env.local (local overrides, not committed) 10 | * 3. .env.[NODE_ENV] (environment-specific: .env.development, .env.production) 11 | * 4. .env.[NODE_ENV].local (environment-specific local overrides) 12 | */ 13 | 14 | import { join, dirname } from "path"; 15 | 16 | /** 17 | * Load environment files from a directory using Bun's native file reading 18 | * Files are loaded in order, with later files overriding earlier ones 19 | */ 20 | export async function loadEnvFiles( 21 | directory: string, 22 | verbose: boolean = false 23 | ): Promise { 24 | const nodeEnv = process.env.NODE_ENV || "development"; 25 | 26 | // Files to load in order (later overrides earlier) 27 | const envFiles = [ 28 | ".env", 29 | ".env.local", 30 | `.env.${nodeEnv}`, 31 | `.env.${nodeEnv}.local`, 32 | ]; 33 | 34 | // Track which keys were set by our loading (so later files can override) 35 | const loadedKeys = new Set(); 36 | // Snapshot of env vars that existed before we started loading 37 | const preExistingKeys = new Set(Object.keys(process.env)); 38 | 39 | let loadedCount = 0; 40 | 41 | for (const envFile of envFiles) { 42 | const envPath = join(directory, envFile); 43 | const file = Bun.file(envPath); 44 | 45 | if (await file.exists()) { 46 | try { 47 | const content = await file.text(); 48 | const vars = parseEnvFile(content); 49 | 50 | for (const [key, value] of Object.entries(vars)) { 51 | // Don't override pre-existing env vars (CLI/system take precedence) 52 | // But DO allow later .env files to override earlier .env files 53 | if (!preExistingKeys.has(key) || loadedKeys.has(key)) { 54 | process.env[key] = value; 55 | loadedKeys.add(key); 56 | } 57 | } 58 | 59 | loadedCount++; 60 | if (verbose) { 61 | console.error(`[env] Loaded: ${envFile} (${Object.keys(vars).length} vars)`); 62 | } 63 | } catch (err) { 64 | if (verbose) { 65 | console.error(`[env] Failed to load ${envFile}: ${(err as Error).message}`); 66 | } 67 | } 68 | } 69 | } 70 | 71 | return loadedCount; 72 | } 73 | 74 | /** 75 | * Parse .env file content into key-value pairs 76 | * Supports: 77 | * - KEY=value 78 | * - KEY="quoted value" 79 | * - KEY='single quoted' 80 | * - # comments 81 | * - Empty lines 82 | * - Multiline values with quotes 83 | */ 84 | function parseEnvFile(content: string): Record { 85 | const vars: Record = {}; 86 | const lines = content.split("\n"); 87 | 88 | let currentKey: string | null = null; 89 | let currentValue: string[] = []; 90 | let inMultiline = false; 91 | let quoteChar: string | null = null; 92 | 93 | for (const line of lines) { 94 | // Skip empty lines and comments (unless in multiline) 95 | if (!inMultiline && (line.trim() === "" || line.trim().startsWith("#"))) { 96 | continue; 97 | } 98 | 99 | if (inMultiline) { 100 | // Continue collecting multiline value 101 | currentValue.push(line); 102 | 103 | // Check if this line ends the multiline 104 | if (line.trimEnd().endsWith(quoteChar!)) { 105 | const fullValue = currentValue.join("\n"); 106 | // Remove the closing quote 107 | vars[currentKey!] = fullValue.slice(0, -1); 108 | inMultiline = false; 109 | currentKey = null; 110 | currentValue = []; 111 | quoteChar = null; 112 | } 113 | continue; 114 | } 115 | 116 | // Parse KEY=value 117 | const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)/); 118 | if (!match) continue; 119 | 120 | const key = match[1]; 121 | const rawValue = match[2]; 122 | if (!key || rawValue === undefined) continue; 123 | 124 | let value = rawValue.trim(); 125 | 126 | // Handle quoted values 127 | if ((value.startsWith('"') && value.endsWith('"')) || 128 | (value.startsWith("'") && value.endsWith("'"))) { 129 | // Simple quoted value on one line 130 | vars[key] = value.slice(1, -1); 131 | } else if (value.startsWith('"') || value.startsWith("'")) { 132 | // Start of multiline quoted value 133 | inMultiline = true; 134 | currentKey = key; 135 | quoteChar = value[0] ?? null; 136 | currentValue = [value.slice(1)]; // Remove opening quote 137 | } else { 138 | // Unquoted value - remove inline comments 139 | const commentIndex = value.indexOf(" #"); 140 | if (commentIndex !== -1) { 141 | value = value.slice(0, commentIndex).trim(); 142 | } 143 | vars[key] = value; 144 | } 145 | } 146 | 147 | return vars; 148 | } 149 | 150 | /** 151 | * Get a list of env files that would be loaded from a directory 152 | */ 153 | export async function getEnvFilesInDirectory(directory: string): Promise { 154 | const nodeEnv = process.env.NODE_ENV || "development"; 155 | const envFiles = [ 156 | ".env", 157 | ".env.local", 158 | `.env.${nodeEnv}`, 159 | `.env.${nodeEnv}.local`, 160 | ]; 161 | 162 | const existing: string[] = []; 163 | for (const envFile of envFiles) { 164 | const envPath = join(directory, envFile); 165 | if (await Bun.file(envPath).exists()) { 166 | existing.push(envFile); 167 | } 168 | } 169 | 170 | return existing; 171 | } 172 | -------------------------------------------------------------------------------- /src/failure-menu.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Failure Menu - Auto-Heal Retry Loop 3 | * 4 | * Shows a menu when a command fails with non-zero exit code: 5 | * - [R]etry: Re-run the same command with same arguments 6 | * - [F]ix with AI: Feed stderr back into context and ask model to fix 7 | * - [Q]uit: Exit with the original error code 8 | * 9 | * Only shown in interactive mode (not print mode). 10 | */ 11 | 12 | import { 13 | createPrompt, 14 | useState, 15 | useKeypress, 16 | isEnterKey, 17 | isUpKey, 18 | isDownKey, 19 | usePrefix, 20 | makeTheme, 21 | } from "@inquirer/core"; 22 | 23 | /** Result from the failure menu */ 24 | export interface FailureMenuResult { 25 | action: "retry" | "fix" | "quit"; 26 | } 27 | 28 | /** Menu option */ 29 | interface MenuOption { 30 | key: string; 31 | label: string; 32 | action: FailureMenuResult["action"]; 33 | } 34 | 35 | interface FailureMenuConfig { 36 | exitCode: number; 37 | stderr: string; 38 | stdout: string; 39 | } 40 | 41 | /** 42 | * Build a follow-up prompt that includes error context 43 | * to help the AI fix its mistake 44 | */ 45 | export function buildFixPrompt( 46 | originalPrompt: string, 47 | stderr: string, 48 | stdout: string, 49 | exitCode: number 50 | ): string { 51 | const parts: string[] = []; 52 | 53 | parts.push("The previous command failed. Please analyze the error and fix your approach.\n"); 54 | parts.push(`Exit code: ${exitCode}\n`); 55 | 56 | if (stderr.trim()) { 57 | parts.push("\n--- STDERR ---"); 58 | parts.push(stderr.trim()); 59 | parts.push("--- END STDERR ---\n"); 60 | } 61 | 62 | if (stdout.trim()) { 63 | parts.push("\n--- STDOUT (partial) ---"); 64 | // Limit stdout to avoid context explosion 65 | const truncatedStdout = stdout.length > 2000 66 | ? stdout.slice(-2000) + "\n... (truncated)" 67 | : stdout; 68 | parts.push(truncatedStdout.trim()); 69 | parts.push("--- END STDOUT ---\n"); 70 | } 71 | 72 | parts.push("\nPlease try again, fixing the issue described above."); 73 | parts.push("\nOriginal request:"); 74 | parts.push(originalPrompt); 75 | 76 | return parts.join("\n"); 77 | } 78 | 79 | /** 80 | * Interactive failure menu prompt 81 | */ 82 | export const failureMenu = createPrompt( 83 | (config, done) => { 84 | const { exitCode, stderr } = config; 85 | const prefix = usePrefix({ status: "idle", theme: makeTheme({}) }); 86 | 87 | const [cursor, setCursor] = useState(0); 88 | 89 | // Build menu options 90 | const options: MenuOption[] = [ 91 | { key: "r", label: "Retry - run the same command again", action: "retry" }, 92 | { key: "f", label: "Fix with AI - feed error back and retry", action: "fix" }, 93 | { key: "q", label: "Quit - exit with error code", action: "quit" }, 94 | ]; 95 | 96 | useKeypress((key) => { 97 | if (isEnterKey(key)) { 98 | const option = options[cursor]; 99 | if (option) { 100 | done({ action: option.action }); 101 | } 102 | return; 103 | } 104 | 105 | if (key.name === "escape" || key.name === "q") { 106 | done({ action: "quit" }); 107 | return; 108 | } 109 | 110 | if (isUpKey(key)) { 111 | setCursor(Math.max(0, cursor - 1)); 112 | return; 113 | } 114 | 115 | if (isDownKey(key)) { 116 | setCursor(Math.min(options.length - 1, cursor + 1)); 117 | return; 118 | } 119 | 120 | // Shortcut keys 121 | if (key.name === "r") { 122 | done({ action: "retry" }); 123 | return; 124 | } 125 | if (key.name === "f") { 126 | done({ action: "fix" }); 127 | return; 128 | } 129 | }); 130 | 131 | // Render 132 | const lines: string[] = []; 133 | 134 | lines.push(""); 135 | lines.push(`${prefix} \x1b[31m\x1b[1mCommand failed (exit code ${exitCode}).\x1b[0m What would you like to do?`); 136 | 137 | // Show truncated stderr preview if available 138 | if (stderr.trim()) { 139 | const preview = stderr.trim().split("\n").slice(0, 3).join("\n"); 140 | const truncated = stderr.trim().split("\n").length > 3 ? "\n ..." : ""; 141 | lines.push(""); 142 | lines.push(`\x1b[90m ${preview.replace(/\n/g, "\n ")}${truncated}\x1b[0m`); 143 | } 144 | 145 | lines.push(""); 146 | 147 | for (let i = 0; i < options.length; i++) { 148 | const opt = options[i]!; 149 | const isSelected = i === cursor; 150 | const highlight = isSelected ? "\x1b[7m" : ""; 151 | const reset = isSelected ? "\x1b[27m" : ""; 152 | const keyHint = `\x1b[36m[${opt.key.toUpperCase()}]\x1b[0m`; 153 | lines.push(` ${highlight}${keyHint} ${opt.label}${reset}`); 154 | } 155 | 156 | lines.push(""); 157 | lines.push(`\x1b[90mUse arrow keys to navigate, Enter to select, or press shortcut key\x1b[0m`); 158 | 159 | return lines.join("\n"); 160 | } 161 | ); 162 | 163 | /** 164 | * Show the failure menu and return the selected action 165 | * 166 | * @param exitCode - The exit code from the failed command 167 | * @param stderr - Captured stderr from the command 168 | * @param stdout - Captured stdout from the command 169 | * @returns The selected action, or "quit" if cancelled 170 | */ 171 | export async function showFailureMenu( 172 | exitCode: number, 173 | stderr: string, 174 | stdout: string 175 | ): Promise { 176 | // Don't show menu if not a TTY 177 | if (!process.stdin.isTTY) { 178 | return { action: "quit" }; 179 | } 180 | 181 | try { 182 | const result = await failureMenu({ 183 | exitCode, 184 | stderr, 185 | stdout, 186 | }); 187 | 188 | return result; 189 | } catch { 190 | // User cancelled (Ctrl+C) or other error 191 | return { action: "quit" }; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/stream.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Stream utilities for teeing, collecting, and piping readable streams. 3 | * Enables simultaneous display to console and capture for programmatic use. 4 | */ 5 | 6 | import { StreamingMarkdownRenderer } from "./markdown-renderer"; 7 | 8 | /** 9 | * Tee a readable stream into two independent streams. 10 | * Both streams will receive identical data from the source. 11 | * 12 | * @param readable - Source readable stream to tee 13 | * @returns Tuple of [streamA, streamB] that can be consumed independently 14 | */ 15 | export function teeStream(readable: ReadableStream): [ReadableStream, ReadableStream] { 16 | return readable.tee(); 17 | } 18 | 19 | /** 20 | * Collect a readable stream into a string. 21 | * Consumes the entire stream and returns the concatenated content. 22 | * 23 | * @param readable - Source readable stream to collect 24 | * @returns Promise resolving to the collected string content 25 | */ 26 | export async function collectStream(readable: ReadableStream): Promise { 27 | const reader = readable.getReader(); 28 | const decoder = new TextDecoder(); 29 | const chunks: string[] = []; 30 | 31 | while (true) { 32 | const { done, value } = await reader.read(); 33 | if (done) break; 34 | chunks.push(decoder.decode(value, { stream: true })); 35 | } 36 | 37 | // Flush any remaining bytes 38 | chunks.push(decoder.decode()); 39 | 40 | return chunks.join(""); 41 | } 42 | 43 | /** 44 | * Pipe a readable stream to process.stdout. 45 | * Writes each chunk to stdout as it arrives, enabling real-time display. 46 | * 47 | * @param readable - Source readable stream to pipe 48 | * @returns Promise resolving when the stream is fully piped 49 | */ 50 | export async function pipeToStdout(readable: ReadableStream): Promise { 51 | const reader = readable.getReader(); 52 | 53 | while (true) { 54 | const { done, value } = await reader.read(); 55 | if (done) break; 56 | await Bun.write(Bun.stdout, value); 57 | } 58 | } 59 | 60 | /** 61 | * Pipe a readable stream to process.stderr. 62 | * Writes each chunk to stderr as it arrives, enabling real-time display. 63 | * 64 | * @param readable - Source readable stream to pipe 65 | * @returns Promise resolving when the stream is fully piped 66 | */ 67 | export async function pipeToStderr(readable: ReadableStream): Promise { 68 | const reader = readable.getReader(); 69 | 70 | while (true) { 71 | const { done, value } = await reader.read(); 72 | if (done) break; 73 | await Bun.write(Bun.stderr, value); 74 | } 75 | } 76 | 77 | /** 78 | * Tee and process a stream: pipe to stdout while collecting content. 79 | * This is the main utility for "teeing" - simultaneous display and capture. 80 | * 81 | * @param readable - Source readable stream 82 | * @returns Promise resolving to the collected string content 83 | */ 84 | export async function teeToStdoutAndCollect(readable: ReadableStream): Promise { 85 | const [displayStream, collectStream_] = teeStream(readable); 86 | 87 | // Run both operations in parallel 88 | const [, collected] = await Promise.all([ 89 | pipeToStdout(displayStream), 90 | collectStream(collectStream_), 91 | ]); 92 | 93 | return collected; 94 | } 95 | 96 | /** 97 | * Tee and process a stream: pipe to stderr while collecting content. 98 | * 99 | * @param readable - Source readable stream 100 | * @returns Promise resolving to the collected string content 101 | */ 102 | export async function teeToStderrAndCollect(readable: ReadableStream): Promise { 103 | const [displayStream, collectStream_] = teeStream(readable); 104 | 105 | // Run both operations in parallel 106 | const [, collected] = await Promise.all([ 107 | pipeToStderr(displayStream), 108 | collectStream(collectStream_), 109 | ]); 110 | 111 | return collected; 112 | } 113 | 114 | /** 115 | * Pipe a readable stream to stdout with markdown rendering. 116 | * Renders markdown to terminal-formatted output with syntax highlighting. 117 | * 118 | * @param readable - Source readable stream 119 | * @param renderer - Streaming markdown renderer instance 120 | * @returns Promise resolving when the stream is fully piped 121 | */ 122 | export async function pipeToStdoutWithMarkdown( 123 | readable: ReadableStream, 124 | renderer: StreamingMarkdownRenderer 125 | ): Promise { 126 | const reader = readable.getReader(); 127 | const decoder = new TextDecoder(); 128 | 129 | while (true) { 130 | const { done, value } = await reader.read(); 131 | if (done) break; 132 | 133 | const chunk = decoder.decode(value, { stream: true }); 134 | const rendered = renderer.processChunk(chunk); 135 | if (rendered) { 136 | await Bun.write(Bun.stdout, rendered); 137 | } 138 | } 139 | 140 | // Flush any remaining content 141 | const remaining = renderer.flush(); 142 | if (remaining) { 143 | await Bun.write(Bun.stdout, remaining + "\n"); 144 | } 145 | } 146 | 147 | /** 148 | * Tee and process a stream: pipe to stdout with markdown rendering while collecting content. 149 | * 150 | * @param readable - Source readable stream 151 | * @param renderer - Streaming markdown renderer instance 152 | * @returns Promise resolving to the collected string content (raw, unrendered) 153 | */ 154 | export async function teeToStdoutWithMarkdownAndCollect( 155 | readable: ReadableStream, 156 | renderer: StreamingMarkdownRenderer 157 | ): Promise { 158 | const [displayStream, collectStream_] = teeStream(readable); 159 | 160 | // Run both operations in parallel 161 | const [, collected] = await Promise.all([ 162 | pipeToStdoutWithMarkdown(displayStream, renderer), 163 | collectStream(collectStream_), 164 | ]); 165 | 166 | return collected; 167 | } 168 | -------------------------------------------------------------------------------- /src/markdown-renderer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from "bun:test"; 2 | import { 3 | renderMarkdown, 4 | StreamingMarkdownRenderer, 5 | supportsRichRendering, 6 | createStreamingRenderer, 7 | } from "./markdown-renderer"; 8 | 9 | describe("markdown-renderer", () => { 10 | describe("renderMarkdown", () => { 11 | it("renders empty string for empty input", () => { 12 | expect(renderMarkdown("")).toBe(""); 13 | expect(renderMarkdown(" ")).toBe(""); 14 | }); 15 | 16 | it("renders plain text", () => { 17 | const result = renderMarkdown("Hello world"); 18 | // Should contain the text (possibly with ANSI codes) 19 | expect(result).toContain("Hello world"); 20 | }); 21 | 22 | it("renders headings with formatting", () => { 23 | const result = renderMarkdown("# Heading 1\n\nSome text"); 24 | // Should contain heading text 25 | expect(result).toContain("Heading 1"); 26 | }); 27 | 28 | it("renders code blocks", () => { 29 | const result = renderMarkdown("```javascript\nconst x = 1;\n```"); 30 | // Should contain the code 31 | expect(result).toContain("const"); 32 | expect(result).toContain("x"); 33 | }); 34 | 35 | it("renders inline code", () => { 36 | const result = renderMarkdown("Use `console.log` for debugging"); 37 | expect(result).toContain("console.log"); 38 | }); 39 | 40 | it("renders bold and italic", () => { 41 | const result = renderMarkdown("**bold** and *italic*"); 42 | expect(result).toContain("bold"); 43 | expect(result).toContain("italic"); 44 | }); 45 | 46 | it("renders lists", () => { 47 | const result = renderMarkdown("- item 1\n- item 2\n- item 3"); 48 | expect(result).toContain("item 1"); 49 | expect(result).toContain("item 2"); 50 | expect(result).toContain("item 3"); 51 | }); 52 | 53 | it("handles malformed markdown gracefully", () => { 54 | // Should not throw 55 | const result = renderMarkdown("```\nunclosed code block"); 56 | expect(typeof result).toBe("string"); 57 | }); 58 | }); 59 | 60 | describe("StreamingMarkdownRenderer", () => { 61 | let renderer: StreamingMarkdownRenderer; 62 | 63 | beforeEach(() => { 64 | renderer = new StreamingMarkdownRenderer({ enabled: true }); 65 | }); 66 | 67 | describe("with rendering enabled", () => { 68 | it("buffers content until paragraph breaks", () => { 69 | // Single chunk without paragraph break should buffer 70 | const result1 = renderer.processChunk("Hello "); 71 | const result2 = renderer.processChunk("world"); 72 | 73 | // No paragraph break yet, should be buffering 74 | expect(result1 + result2).toBe(""); 75 | 76 | // Flush should return the content 77 | const flushed = renderer.flush(); 78 | expect(flushed).toContain("Hello"); 79 | expect(flushed).toContain("world"); 80 | }); 81 | 82 | it("renders content at paragraph breaks", () => { 83 | const result = renderer.processChunk("First paragraph.\n\nSecond paragraph."); 84 | 85 | // Should have rendered up to the paragraph break 86 | expect(result).toContain("First paragraph"); 87 | 88 | // Flush the rest 89 | const flushed = renderer.flush(); 90 | expect(flushed).toContain("Second paragraph"); 91 | }); 92 | 93 | it("buffers code blocks until complete", () => { 94 | // Start a code block 95 | renderer.processChunk("```javascript\n"); 96 | renderer.processChunk("const x = 1;\n"); 97 | 98 | // Code block not closed, should still be buffering 99 | const result = renderer.processChunk("```\n\n"); 100 | 101 | // Now it should be processed 102 | expect(result).toBeDefined(); 103 | }); 104 | 105 | it("reset clears the buffer", () => { 106 | renderer.processChunk("Some content"); 107 | renderer.reset(); 108 | const flushed = renderer.flush(); 109 | expect(flushed).toBe(""); 110 | }); 111 | }); 112 | 113 | describe("with rendering disabled (raw mode)", () => { 114 | beforeEach(() => { 115 | renderer = new StreamingMarkdownRenderer({ enabled: false }); 116 | }); 117 | 118 | it("passes through content unchanged", () => { 119 | const result1 = renderer.processChunk("# Heading\n\n"); 120 | const result2 = renderer.processChunk("Some **bold** text"); 121 | 122 | expect(result1).toBe("# Heading\n\n"); 123 | expect(result2).toBe("Some **bold** text"); 124 | }); 125 | 126 | it("flush returns empty since raw mode passes through immediately", () => { 127 | // In raw mode, processChunk passes through immediately, so nothing is buffered 128 | const result = renderer.processChunk("partial"); 129 | expect(result).toBe("partial"); 130 | // Buffer should be empty since we passed through 131 | const flushed = renderer.flush(); 132 | expect(flushed).toBe(""); 133 | }); 134 | }); 135 | }); 136 | 137 | describe("supportsRichRendering", () => { 138 | it("returns a boolean", () => { 139 | const result = supportsRichRendering(); 140 | expect(typeof result).toBe("boolean"); 141 | }); 142 | }); 143 | 144 | describe("createStreamingRenderer", () => { 145 | it("creates a renderer", () => { 146 | const renderer = createStreamingRenderer(); 147 | expect(renderer).toBeInstanceOf(StreamingMarkdownRenderer); 148 | }); 149 | 150 | it("creates raw renderer when forceRaw is true", () => { 151 | const renderer = createStreamingRenderer(true); 152 | // In raw mode, content should pass through unchanged 153 | const result = renderer.processChunk("# Test"); 154 | expect(result).toBe("# Test"); 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/trust.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeEach, afterEach } from "bun:test"; 2 | import { mkdir, rm } from "fs/promises"; 3 | import { join } from "path"; 4 | import { tmpdir } from "os"; 5 | import { 6 | extractDomain, 7 | loadKnownHosts, 8 | saveKnownHosts, 9 | isDomainTrusted, 10 | addTrustedDomain, 11 | getKnownHostsPath, 12 | } from "./trust"; 13 | 14 | // We'll test the file-based functions using a mock approach 15 | // by temporarily modifying the home directory 16 | 17 | describe("extractDomain", () => { 18 | test("extracts domain from https URL", () => { 19 | expect(extractDomain("https://example.com/path/file.md")).toBe("example.com"); 20 | }); 21 | 22 | test("extracts domain from http URL", () => { 23 | expect(extractDomain("http://localhost:3000/file.md")).toBe("localhost"); 24 | }); 25 | 26 | test("extracts domain from GitHub gist URL", () => { 27 | expect(extractDomain("https://gist.github.com/user/abc123")).toBe("gist.github.com"); 28 | }); 29 | 30 | test("extracts domain from raw GitHub URL", () => { 31 | expect(extractDomain("https://raw.githubusercontent.com/user/repo/main/file.md")).toBe("raw.githubusercontent.com"); 32 | }); 33 | 34 | test("returns input for invalid URL", () => { 35 | expect(extractDomain("not-a-url")).toBe("not-a-url"); 36 | }); 37 | 38 | test("extracts domain with subdomain", () => { 39 | expect(extractDomain("https://api.github.com/repos")).toBe("api.github.com"); 40 | }); 41 | }); 42 | 43 | describe("known hosts file operations", () => { 44 | let tempDir: string; 45 | let originalHome: string | undefined; 46 | 47 | beforeEach(async () => { 48 | // Create temp directory for testing 49 | tempDir = join(tmpdir(), `trust-test-${Date.now()}`); 50 | await mkdir(tempDir, { recursive: true }); 51 | await mkdir(join(tempDir, ".mdflow"), { recursive: true }); 52 | 53 | // Store original HOME 54 | originalHome = process.env.HOME; 55 | }); 56 | 57 | afterEach(async () => { 58 | // Restore HOME 59 | if (originalHome !== undefined) { 60 | process.env.HOME = originalHome; 61 | } 62 | 63 | // Cleanup temp directory 64 | try { 65 | await rm(tempDir, { recursive: true, force: true }); 66 | } catch { 67 | // Ignore cleanup errors 68 | } 69 | }); 70 | 71 | test("getKnownHostsPath returns expected path", () => { 72 | const path = getKnownHostsPath(); 73 | expect(path).toContain(".mdflow"); 74 | expect(path).toContain("known_hosts"); 75 | }); 76 | 77 | test("loadKnownHosts returns empty set when file does not exist", async () => { 78 | const hosts = await loadKnownHosts(); 79 | // May return empty or existing hosts depending on user's system 80 | expect(hosts).toBeInstanceOf(Set); 81 | }); 82 | 83 | test("saveKnownHosts and loadKnownHosts round-trip", async () => { 84 | // This test will use the actual known_hosts file 85 | // Save current state first 86 | const originalHosts = await loadKnownHosts(); 87 | 88 | // Add test domains 89 | const testDomains = new Set([ 90 | ...originalHosts, 91 | "test-domain-1.example.com", 92 | "test-domain-2.example.com", 93 | ]); 94 | 95 | await saveKnownHosts(testDomains); 96 | 97 | const loadedHosts = await loadKnownHosts(); 98 | 99 | expect(loadedHosts.has("test-domain-1.example.com")).toBe(true); 100 | expect(loadedHosts.has("test-domain-2.example.com")).toBe(true); 101 | 102 | // Restore original state 103 | await saveKnownHosts(originalHosts); 104 | }); 105 | 106 | test("isDomainTrusted returns false for unknown domain", async () => { 107 | const isTrusted = await isDomainTrusted("https://definitely-not-trusted-domain-12345.example.com/file.md"); 108 | expect(isTrusted).toBe(false); 109 | }); 110 | 111 | test("addTrustedDomain adds domain and isDomainTrusted returns true", async () => { 112 | const testUrl = "https://tofu-test-unique-domain-98765.example.com/file.md"; 113 | const testDomain = "tofu-test-unique-domain-98765.example.com"; 114 | 115 | // Save original state 116 | const originalHosts = await loadKnownHosts(); 117 | 118 | try { 119 | // Ensure domain is not already trusted 120 | const initiallyTrusted = await isDomainTrusted(testUrl); 121 | expect(initiallyTrusted).toBe(false); 122 | 123 | // Add the domain 124 | await addTrustedDomain(testUrl); 125 | 126 | // Now it should be trusted 127 | const nowTrusted = await isDomainTrusted(testUrl); 128 | expect(nowTrusted).toBe(true); 129 | } finally { 130 | // Restore original state (remove test domain) 131 | originalHosts.delete(testDomain); 132 | await saveKnownHosts(originalHosts); 133 | } 134 | }); 135 | }); 136 | 137 | describe("saveKnownHosts file format", () => { 138 | test("saves hosts with header comments", async () => { 139 | const originalHosts = await loadKnownHosts(); 140 | 141 | const testHosts = new Set([...originalHosts, "format-test.example.com"]); 142 | await saveKnownHosts(testHosts); 143 | 144 | const path = getKnownHostsPath(); 145 | const content = await Bun.file(path).text(); 146 | 147 | expect(content).toContain("# mdflow known hosts"); 148 | expect(content).toContain("format-test.example.com"); 149 | 150 | // Restore original 151 | originalHosts.delete("format-test.example.com"); 152 | await saveKnownHosts(originalHosts); 153 | }); 154 | 155 | test("loadKnownHosts ignores comment lines", async () => { 156 | const originalHosts = await loadKnownHosts(); 157 | 158 | // The saved file has comments, verify they're not loaded as domains 159 | const hosts = await loadKnownHosts(); 160 | 161 | // Comments should not appear as domains 162 | for (const host of hosts) { 163 | expect(host.startsWith("#")).toBe(false); 164 | } 165 | 166 | // Restore 167 | await saveKnownHosts(originalHosts); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /src/smoke-pipe.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { 3 | spawnMdWithPipe, 4 | spawnMd, 5 | createTempDir, 6 | createTestAgent, 7 | createTestFiles, 8 | CLI_PATH, 9 | } from "./test-utils"; 10 | import { spawn } from "bun"; 11 | 12 | /** 13 | * Smoke tests for piping between .md agent files. 14 | * Uses MA_COMMAND=echo to simulate LLM responses without actual API calls. 15 | * These tests verify the stdin/stdout piping mechanism works correctly. 16 | */ 17 | 18 | describe("smoke: pipe between agents", () => { 19 | let testDir: string; 20 | let cleanup: () => Promise; 21 | 22 | beforeAll(async () => { 23 | const temp = await createTempDir("md-smoke-pipe-"); 24 | testDir = temp.tempDir; 25 | cleanup = temp.cleanup; 26 | }); 27 | 28 | afterAll(async () => { 29 | await cleanup(); 30 | }); 31 | 32 | test("stdin is passed to agent via _stdin variable", async () => { 33 | const agentFile = await createTestAgent( 34 | testDir, 35 | "echo-stdin.echo.md", 36 | `--- 37 | --- 38 | Input: {{ _stdin }} 39 | Process this input: 40 | ` 41 | ); 42 | 43 | const result = await spawnMdWithPipe(agentFile, "hello world", [], { 44 | env: { MA_COMMAND: "echo" }, 45 | }); 46 | 47 | expect(result.exitCode).toBe(0); 48 | expect(result.stdout).toContain("Process this input:"); 49 | expect(result.stdout).toContain("hello world"); 50 | }); 51 | 52 | test("pipe: agent1 | agent2 (two-stage pipeline)", async () => { 53 | const paths = await createTestFiles(testDir, { 54 | "stage1.echo.md": `--- 55 | --- 56 | STAGE1_OUTPUT: processed 57 | `, 58 | "stage2.echo.md": `--- 59 | --- 60 | STAGE2_RECEIVED: {{ _stdin }} 61 | `, 62 | }); 63 | 64 | // Multi-stage pipeline requires custom bash command 65 | const proc = spawn({ 66 | cmd: [ 67 | "bash", 68 | "-c", 69 | `echo "initial" | bun run ${CLI_PATH} ${paths["stage1.echo.md"]} | bun run ${CLI_PATH} ${paths["stage2.echo.md"]}`, 70 | ], 71 | stdout: "pipe", 72 | stderr: "pipe", 73 | env: { ...process.env, MA_COMMAND: "echo" }, 74 | }); 75 | 76 | const output = await new Response(proc.stdout).text(); 77 | const exitCode = await proc.exited; 78 | 79 | expect(exitCode).toBe(0); 80 | expect(output).toContain("STAGE2_RECEIVED:"); 81 | expect(output).toContain("STAGE1_OUTPUT: processed"); 82 | }); 83 | 84 | test("pipe: agent1 | agent2 | agent3 (three-stage pipeline)", async () => { 85 | const paths = await createTestFiles(testDir, { 86 | "three-stage1.echo.md": `--- 87 | --- 88 | [STEP1] 89 | `, 90 | "three-stage2.echo.md": `--- 91 | --- 92 | {{ _stdin }} 93 | [STEP2] 94 | `, 95 | "three-stage3.echo.md": `--- 96 | --- 97 | {{ _stdin }} 98 | [STEP3_FINAL] 99 | `, 100 | }); 101 | 102 | const proc = spawn({ 103 | cmd: [ 104 | "bash", 105 | "-c", 106 | `echo "start" | bun run ${CLI_PATH} ${paths["three-stage1.echo.md"]} | bun run ${CLI_PATH} ${paths["three-stage2.echo.md"]} | bun run ${CLI_PATH} ${paths["three-stage3.echo.md"]}`, 107 | ], 108 | stdout: "pipe", 109 | stderr: "pipe", 110 | env: { ...process.env, MA_COMMAND: "echo" }, 111 | }); 112 | 113 | const output = await new Response(proc.stdout).text(); 114 | const exitCode = await proc.exited; 115 | 116 | expect(exitCode).toBe(0); 117 | expect(output).toContain("[STEP3_FINAL]"); 118 | expect(output).toContain("[STEP2]"); 119 | expect(output).toContain("[STEP1]"); 120 | }); 121 | 122 | test("template vars work in piped context", async () => { 123 | const agent = await createTestAgent( 124 | testDir, 125 | "template-pipe.echo.md", 126 | `--- 127 | _name: "" 128 | --- 129 | Hello {{ _name }}! Input: {{ _stdin }} 130 | ` 131 | ); 132 | 133 | const result = await spawnMdWithPipe(agent, "context", ["--_name", "World"], { 134 | env: { MA_COMMAND: "echo" }, 135 | }); 136 | 137 | expect(result.exitCode).toBe(0); 138 | expect(result.stdout).toContain("Hello World!"); 139 | expect(result.stdout).toContain("context"); 140 | }); 141 | 142 | test("frontmatter flags are passed correctly in pipe", async () => { 143 | const agent = await createTestAgent( 144 | testDir, 145 | "flags-pipe.echo.md", 146 | `--- 147 | model: test-model 148 | verbose: true 149 | --- 150 | Body content 151 | ` 152 | ); 153 | 154 | const result = await spawnMdWithPipe(agent, "input", ["--_dry-run"]); 155 | 156 | expect(result.exitCode).toBe(0); 157 | expect(result.stdout).toContain("--model"); 158 | expect(result.stdout).toContain("test-model"); 159 | }); 160 | 161 | test("empty stdin is handled gracefully", async () => { 162 | const agent = await createTestAgent( 163 | testDir, 164 | "empty-stdin.echo.md", 165 | `--- 166 | --- 167 | No stdin expected 168 | ` 169 | ); 170 | 171 | const result = await spawnMd([agent], { env: { MA_COMMAND: "echo" } }); 172 | 173 | expect(result.exitCode).toBe(0); 174 | expect(result.stdout).toContain("No stdin expected"); 175 | expect(result.stdout).not.toContain(""); 176 | }); 177 | 178 | test("multiline stdin is preserved through pipe", async () => { 179 | const agent = await createTestAgent( 180 | testDir, 181 | "multiline.echo.md", 182 | `--- 183 | --- 184 | Received: {{ _stdin }} 185 | ` 186 | ); 187 | 188 | // Use printf for multiline 189 | const proc = spawn({ 190 | cmd: ["bash", "-c", `printf "line1\\nline2\\nline3" | bun run ${CLI_PATH} ${agent}`], 191 | stdout: "pipe", 192 | stderr: "pipe", 193 | env: { ...process.env, MA_COMMAND: "echo" }, 194 | }); 195 | 196 | const output = await new Response(proc.stdout).text(); 197 | const exitCode = await proc.exited; 198 | 199 | expect(exitCode).toBe(0); 200 | expect(output).toContain("line1"); 201 | expect(output).toContain("line2"); 202 | expect(output).toContain("line3"); 203 | }); 204 | }); 205 | -------------------------------------------------------------------------------- /src/imports-parser-fuzz.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Property-based/fuzz tests for the import parser 3 | * 4 | * These tests use fast-check to generate random markdown content 5 | * and verify the parser never panics and behaves correctly. 6 | */ 7 | 8 | import { describe, it, expect } from 'bun:test'; 9 | import fc from 'fast-check'; 10 | import { 11 | parseImports, 12 | hasImportsInContent, 13 | findSafeRanges, 14 | } from './imports-parser'; 15 | 16 | describe('imports-parser fuzz tests', () => { 17 | describe('parseImports never throws', () => { 18 | it('handles any random string without throwing', () => { 19 | fc.assert( 20 | fc.property(fc.string(), (input) => { 21 | const result = parseImports(input); 22 | expect(Array.isArray(result)).toBe(true); 23 | }), 24 | { numRuns: 1000 } 25 | ); 26 | }); 27 | 28 | it('handles any unicode string without throwing', () => { 29 | fc.assert( 30 | fc.property(fc.string({ unit: 'grapheme' }), (input) => { 31 | const result = parseImports(input); 32 | expect(Array.isArray(result)).toBe(true); 33 | }), 34 | { numRuns: 500 } 35 | ); 36 | }); 37 | 38 | it('handles very long strings without throwing', () => { 39 | fc.assert( 40 | fc.property(fc.string({ minLength: 10000, maxLength: 50000 }), (input) => { 41 | const result = parseImports(input); 42 | expect(Array.isArray(result)).toBe(true); 43 | }), 44 | { numRuns: 50 } 45 | ); 46 | }); 47 | }); 48 | 49 | describe('findSafeRanges never throws', () => { 50 | it('handles any random string without throwing', () => { 51 | fc.assert( 52 | fc.property(fc.string(), (input) => { 53 | const result = findSafeRanges(input); 54 | expect(Array.isArray(result)).toBe(true); 55 | }), 56 | { numRuns: 1000 } 57 | ); 58 | }); 59 | }); 60 | 61 | describe('hasImportsInContent consistency', () => { 62 | it('hasImportsInContent matches parseImports.length > 0', () => { 63 | fc.assert( 64 | fc.property(fc.string(), (input) => { 65 | const hasImports = hasImportsInContent(input); 66 | const parseResult = parseImports(input); 67 | expect(hasImports).toBe(parseResult.length > 0); 68 | }), 69 | { numRuns: 500 } 70 | ); 71 | }); 72 | }); 73 | 74 | describe('random markdown with code fences', () => { 75 | const markdownWithFencesArb = fc.array( 76 | fc.oneof( 77 | fc.string({ minLength: 0, maxLength: 100 }), 78 | fc.tuple( 79 | fc.constantFrom('```', '~~~', '````'), 80 | fc.constantFrom('', 'js', 'ts', 'python'), 81 | fc.string({ minLength: 0, maxLength: 200 }), 82 | fc.constantFrom('```', '~~~', '````') 83 | ).map(([open, lang, content, close]) => `${open}${lang}\n${content}\n${close}`), 84 | fc.string({ minLength: 1, maxLength: 50 }).map((s) => `\`${s.replace(/`/g, '')}\``), 85 | fc.constantFrom('@./file.md', '@~/config.yaml', '@/absolute/path.ts'), 86 | fc.constantFrom('@https://example.com', '@http://localhost:3000/api'), 87 | fc.string({ minLength: 1, maxLength: 30 }).map((s) => `!\`${s.replace(/`/g, '')}\``), 88 | ), 89 | { minLength: 0, maxLength: 10 } 90 | ).map((parts) => parts.join('\n')); 91 | 92 | it('never throws on random markdown with fences', () => { 93 | fc.assert( 94 | fc.property(markdownWithFencesArb, (input) => { 95 | const result = parseImports(input); 96 | expect(Array.isArray(result)).toBe(true); 97 | }), 98 | { numRuns: 500 } 99 | ); 100 | }); 101 | 102 | it('imports inside code fences are ignored', () => { 103 | fc.assert( 104 | fc.property( 105 | fc.string({ minLength: 0, maxLength: 50 }).filter(s => !s.includes('```') && !s.includes('`')), 106 | (content) => { 107 | const fenced = '```\n@./inside-fence.md\n' + content + '\n```'; 108 | const result = parseImports(fenced); 109 | const fileImports = result.filter(r => r.type === 'file' && r.original.includes('inside-fence')); 110 | expect(fileImports).toHaveLength(0); 111 | } 112 | ), 113 | { numRuns: 200 } 114 | ); 115 | }); 116 | }); 117 | 118 | describe('safe range property tests', () => { 119 | it('safe ranges never overlap', () => { 120 | fc.assert( 121 | fc.property(fc.string(), (input) => { 122 | const ranges = findSafeRanges(input); 123 | for (let i = 1; i < ranges.length; i++) { 124 | const prev = ranges[i - 1]!; 125 | const curr = ranges[i]!; 126 | expect(prev.end).toBeLessThanOrEqual(curr.start); 127 | } 128 | }), 129 | { numRuns: 500 } 130 | ); 131 | }); 132 | 133 | it('safe ranges stay within content bounds', () => { 134 | fc.assert( 135 | fc.property(fc.string(), (input) => { 136 | const ranges = findSafeRanges(input); 137 | for (const range of ranges) { 138 | expect(range.start).toBeGreaterThanOrEqual(0); 139 | expect(range.end).toBeLessThanOrEqual(input.length); 140 | expect(range.start).toBeLessThanOrEqual(range.end); 141 | } 142 | }), 143 | { numRuns: 500 } 144 | ); 145 | }); 146 | 147 | it('imports only appear within safe ranges', () => { 148 | fc.assert( 149 | fc.property(fc.string(), (input) => { 150 | const ranges = findSafeRanges(input); 151 | const imports = parseImports(input); 152 | for (const imp of imports) { 153 | if (imp.type === 'executable_code_fence') continue; 154 | const inSafeRange = ranges.some( 155 | (range) => imp.index >= range.start && imp.index < range.end 156 | ); 157 | expect(inSafeRange).toBe(true); 158 | } 159 | }), 160 | { numRuns: 500 } 161 | ); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /src/edit-prompt.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { getEditor, getTempFilePath, editPrompt } from "./edit-prompt"; 3 | import { 4 | extractFlag, 5 | createFlagExtractionTests, 6 | createTempDir, 7 | saveEnv, 8 | } from "./test-utils"; 9 | 10 | /** 11 | * Tests for the --_edit flag: 12 | * - --_edit is consumed by md (not passed to command) 13 | * - Opens resolved prompt in $EDITOR before execution 14 | * - Waits for editor to close 15 | * - Prompts user to confirm 16 | * - Returns modified prompt or null if cancelled 17 | */ 18 | 19 | describe("--_edit flag consumption", () => { 20 | const FLAG = "--_edit"; 21 | const testCases = createFlagExtractionTests(FLAG); 22 | 23 | test("--_edit flag is consumed and not passed to command", () => { 24 | const args = [...testCases.atStart.input]; 25 | const found = extractFlag(args, FLAG); 26 | expect(found).toBe(testCases.atStart.expected.flagFound); 27 | expect(args).toEqual(testCases.atStart.expected.remaining); 28 | }); 29 | 30 | test("--_edit flag at end of args is consumed", () => { 31 | const args = [...testCases.atEnd.input]; 32 | const found = extractFlag(args, FLAG); 33 | expect(found).toBe(testCases.atEnd.expected.flagFound); 34 | expect(args).toEqual(testCases.atEnd.expected.remaining); 35 | }); 36 | 37 | test("--_edit flag in middle of args is consumed", () => { 38 | const args = [...testCases.inMiddle.input]; 39 | const found = extractFlag(args, FLAG); 40 | expect(found).toBe(testCases.inMiddle.expected.flagFound); 41 | expect(args).toEqual(testCases.inMiddle.expected.remaining); 42 | }); 43 | 44 | test("no --_edit flag means editFlag is false", () => { 45 | const args = [...testCases.notPresent.input]; 46 | const found = extractFlag(args, FLAG); 47 | expect(found).toBe(testCases.notPresent.expected.flagFound); 48 | expect(args).toEqual(testCases.notPresent.expected.remaining); 49 | }); 50 | }); 51 | 52 | describe("getEditor", () => { 53 | let envSnapshot: ReturnType; 54 | 55 | beforeAll(() => { 56 | envSnapshot = saveEnv(["EDITOR", "VISUAL"]); 57 | }); 58 | 59 | afterAll(() => { 60 | envSnapshot.restore(); 61 | }); 62 | 63 | test("uses $EDITOR when set", () => { 64 | process.env.EDITOR = "nano"; 65 | delete process.env.VISUAL; 66 | expect(getEditor()).toBe("nano"); 67 | }); 68 | 69 | test("uses $VISUAL when $EDITOR is not set", () => { 70 | delete process.env.EDITOR; 71 | process.env.VISUAL = "code --wait"; 72 | expect(getEditor()).toBe("code --wait"); 73 | }); 74 | 75 | test("prefers $EDITOR over $VISUAL", () => { 76 | process.env.EDITOR = "vim"; 77 | process.env.VISUAL = "code --wait"; 78 | expect(getEditor()).toBe("vim"); 79 | }); 80 | 81 | test("falls back to common editors when env not set", () => { 82 | delete process.env.EDITOR; 83 | delete process.env.VISUAL; 84 | const editor = getEditor(); 85 | expect(["vim", "nano", "vi"]).toContain(editor); 86 | }); 87 | }); 88 | 89 | describe("getTempFilePath", () => { 90 | test("generates unique temp file paths", () => { 91 | const path1 = getTempFilePath(); 92 | const path2 = getTempFilePath(); 93 | 94 | expect(path1).not.toBe(path2); 95 | expect(path1).toContain("mdflow-edit"); 96 | expect(path1).toEndWith(".md"); 97 | expect(path2).toContain("mdflow-edit"); 98 | expect(path2).toEndWith(".md"); 99 | }); 100 | 101 | test("uses custom prefix", () => { 102 | const path = getTempFilePath("custom-prefix"); 103 | expect(path).toContain("custom-prefix"); 104 | expect(path).toEndWith(".md"); 105 | }); 106 | }); 107 | 108 | describe("editPrompt function", () => { 109 | let tempDir: string; 110 | let cleanup: () => Promise; 111 | let envSnapshot: ReturnType; 112 | 113 | beforeAll(async () => { 114 | const temp = await createTempDir("md-edit-test-"); 115 | tempDir = temp.tempDir; 116 | cleanup = temp.cleanup; 117 | envSnapshot = saveEnv(["EDITOR"]); 118 | }); 119 | 120 | afterAll(async () => { 121 | envSnapshot.restore(); 122 | await cleanup(); 123 | }); 124 | 125 | test("returns confirmed: false when user declines", async () => { 126 | process.env.EDITOR = "true"; // Unix 'true' command - exits 0 immediately 127 | const result = await editPrompt("Test prompt", { 128 | confirmFn: async () => "n", 129 | }); 130 | expect(result.confirmed).toBe(false); 131 | expect(result.prompt).toBe(null); 132 | }); 133 | 134 | test("returns prompt when user confirms with 'y'", async () => { 135 | process.env.EDITOR = "true"; 136 | const result = await editPrompt("Test prompt content", { 137 | confirmFn: async () => "y", 138 | }); 139 | expect(result.confirmed).toBe(true); 140 | expect(result.prompt).toBe("Test prompt content"); 141 | }); 142 | 143 | test("returns prompt when user confirms with 'yes'", async () => { 144 | process.env.EDITOR = "true"; 145 | const result = await editPrompt("Another test", { 146 | confirmFn: async () => "yes", 147 | }); 148 | expect(result.confirmed).toBe(true); 149 | expect(result.prompt).toBe("Another test"); 150 | }); 151 | 152 | test("returns prompt when user confirms with 'Y' (case insensitive)", async () => { 153 | process.env.EDITOR = "true"; 154 | const result = await editPrompt("Case test", { 155 | confirmFn: async () => "Y", 156 | }); 157 | expect(result.confirmed).toBe(true); 158 | }); 159 | 160 | test("skipConfirm option bypasses confirmation", async () => { 161 | process.env.EDITOR = "true"; 162 | const result = await editPrompt("Skip confirm test", { 163 | skipConfirm: true, 164 | }); 165 | expect(result.confirmed).toBe(true); 166 | expect(result.prompt).toBe("Skip confirm test"); 167 | }); 168 | 169 | test("returns null when editor fails", async () => { 170 | process.env.EDITOR = "false"; // Unix 'false' command - exits 1 171 | const result = await editPrompt("Will fail", { 172 | confirmFn: async () => "y", 173 | }); 174 | expect(result.confirmed).toBe(false); 175 | expect(result.prompt).toBe(null); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /src/dry-run.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { writeFile } from "fs/promises"; 3 | import { join } from "path"; 4 | import { 5 | extractFlag, 6 | createFlagExtractionTests, 7 | spawnMd, 8 | createTempDir, 9 | createTestAgent, 10 | } from "./test-utils"; 11 | 12 | /** 13 | * Tests for the --_dry-run flag: 14 | * - --_dry-run is consumed by md (not passed to command) 15 | * - Prints the resolved command with args 16 | * - Prints the final rendered prompt/body 17 | * - Prints estimated token count 18 | * - Exits with code 0 without running the command 19 | */ 20 | 21 | describe("--_dry-run flag consumption", () => { 22 | const FLAG = "--_dry-run"; 23 | const testCases = createFlagExtractionTests(FLAG); 24 | 25 | test("--_dry-run flag is consumed and not passed to command", () => { 26 | const args = [...testCases.atStart.input]; 27 | const found = extractFlag(args, FLAG); 28 | expect(found).toBe(testCases.atStart.expected.flagFound); 29 | expect(args).toEqual(testCases.atStart.expected.remaining); 30 | }); 31 | 32 | test("--_dry-run flag at end of args is consumed", () => { 33 | const args = [...testCases.atEnd.input]; 34 | const found = extractFlag(args, FLAG); 35 | expect(found).toBe(testCases.atEnd.expected.flagFound); 36 | expect(args).toEqual(testCases.atEnd.expected.remaining); 37 | }); 38 | 39 | test("--_dry-run flag in middle of args is consumed", () => { 40 | const args = [...testCases.inMiddle.input]; 41 | const found = extractFlag(args, FLAG); 42 | expect(found).toBe(testCases.inMiddle.expected.flagFound); 43 | expect(args).toEqual(testCases.inMiddle.expected.remaining); 44 | }); 45 | 46 | test("no --_dry-run flag means dryRun is false", () => { 47 | const args = [...testCases.notPresent.input]; 48 | const found = extractFlag(args, FLAG); 49 | expect(found).toBe(testCases.notPresent.expected.flagFound); 50 | expect(args).toEqual(testCases.notPresent.expected.remaining); 51 | }); 52 | }); 53 | 54 | describe("--_dry-run integration", () => { 55 | let tempDir: string; 56 | let cleanup: () => Promise; 57 | 58 | beforeAll(async () => { 59 | const temp = await createTempDir("md-dry-run-test-"); 60 | tempDir = temp.tempDir; 61 | cleanup = temp.cleanup; 62 | }); 63 | 64 | afterAll(async () => { 65 | await cleanup(); 66 | }); 67 | 68 | test("dry-run shows command and prompt without executing", async () => { 69 | const testFile = await createTestAgent( 70 | tempDir, 71 | "test.claude.md", 72 | `--- 73 | model: opus 74 | --- 75 | Hello, this is a test prompt.` 76 | ); 77 | 78 | const result = await spawnMd([testFile, "--_dry-run"]); 79 | 80 | expect(result.exitCode).toBe(0); 81 | expect(result.stdout).toContain("DRY RUN"); 82 | expect(result.stdout).toContain("Command:"); 83 | expect(result.stdout).toContain("claude"); 84 | expect(result.stdout).toContain("--model"); 85 | expect(result.stdout).toContain("opus"); 86 | expect(result.stdout).toContain("Final Prompt:"); 87 | expect(result.stdout).toContain("Hello, this is a test prompt."); 88 | expect(result.stdout).toContain("Estimated tokens:"); 89 | }); 90 | 91 | test("dry-run with template variables shows substituted values", async () => { 92 | const testFile = await createTestAgent( 93 | tempDir, 94 | "template.claude.md", 95 | `--- 96 | _name: "" 97 | --- 98 | Hello, {{ _name }}! Welcome.` 99 | ); 100 | 101 | const result = await spawnMd([testFile, "--_name", "Alice", "--_dry-run"]); 102 | 103 | expect(result.exitCode).toBe(0); 104 | expect(result.stdout).toContain("DRY RUN"); 105 | expect(result.stdout).toContain("Hello, Alice! Welcome."); 106 | expect(result.stdout).not.toContain("{{ _name }}"); // Template var should be replaced 107 | }); 108 | 109 | test("dry-run with --_command flag shows correct command", async () => { 110 | const testFile = await createTestAgent( 111 | tempDir, 112 | "generic.md", 113 | `--- 114 | model: gpt-4 115 | --- 116 | Test prompt for generic file.` 117 | ); 118 | 119 | const result = await spawnMd([testFile, "--_command", "gemini", "--_dry-run"]); 120 | 121 | expect(result.exitCode).toBe(0); 122 | expect(result.stdout).toContain("DRY RUN"); 123 | expect(result.stdout).toContain("Command:"); 124 | expect(result.stdout).toContain("gemini"); // Should use --_command value 125 | expect(result.stdout).toContain("--model"); 126 | expect(result.stdout).toContain("gpt-4"); 127 | }); 128 | 129 | test("dry-run shows estimated token count", async () => { 130 | // With real tokenization, repeated "A" characters get tokenized efficiently 131 | const promptText = "A".repeat(400); 132 | const testFile = await createTestAgent( 133 | tempDir, 134 | "tokens.claude.md", 135 | `--- 136 | model: opus 137 | --- 138 | ${promptText}` 139 | ); 140 | 141 | const result = await spawnMd([testFile, "--_dry-run"]); 142 | 143 | expect(result.exitCode).toBe(0); 144 | expect(result.stdout).toMatch(/Estimated tokens: ~\d+/); 145 | }); 146 | 147 | test("dry-run does NOT execute the command", async () => { 148 | // Create a file that would fail if actually executed (bad command) 149 | const testFile = await createTestAgent( 150 | tempDir, 151 | "norun.nonexistent-command.md", 152 | `--- 153 | --- 154 | This should not run.` 155 | ); 156 | 157 | const result = await spawnMd([testFile, "--_dry-run"]); 158 | 159 | // Should exit 0 because dry-run prevents execution 160 | expect(result.exitCode).toBe(0); 161 | expect(result.stdout).toContain("DRY RUN"); 162 | expect(result.stdout).toContain("nonexistent-command"); 163 | }); 164 | 165 | test("dry-run with additional passthrough flags shows them in command", async () => { 166 | const testFile = await createTestAgent( 167 | tempDir, 168 | "passthrough.claude.md", 169 | `--- 170 | model: opus 171 | --- 172 | Test prompt.` 173 | ); 174 | 175 | const result = await spawnMd([testFile, "--_dry-run", "--verbose", "--debug"]); 176 | 177 | expect(result.exitCode).toBe(0); 178 | expect(result.stdout).toContain("DRY RUN"); 179 | expect(result.stdout).toContain("--verbose"); 180 | expect(result.stdout).toContain("--debug"); 181 | expect(result.stdout).not.toContain("--_dry-run"); // Should be consumed, not shown 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Zod schemas for frontmatter and config validation 3 | * Minimal validation - most keys pass through to the command 4 | */ 5 | 6 | import { z } from "zod"; 7 | 8 | /** Coerce any primitive value to string (for env vars where YAML may parse as bool/number) */ 9 | const stringCoerce = z.union([z.string(), z.number(), z.boolean()]).transform(v => String(v)); 10 | 11 | // ============================================================================ 12 | // Input Definition Schema (for form-style prompts) 13 | // ============================================================================ 14 | 15 | /** 16 | * Input definition schema for typed prompts 17 | * Validates input type and associated options 18 | */ 19 | const inputDefinitionSchema = z.object({ 20 | type: z.enum(['text', 'select', 'number', 'confirm', 'password']), 21 | description: z.string().optional(), 22 | default: z.union([z.string(), z.number(), z.boolean()]).optional(), 23 | options: z.array(z.string()).optional(), 24 | min: z.number().optional(), 25 | max: z.number().optional(), 26 | required: z.boolean().optional(), 27 | }).refine( 28 | (data) => { 29 | // If type is 'select', options must be provided 30 | if (data.type === 'select' && (!data.options || data.options.length === 0)) { 31 | return false; 32 | } 33 | return true; 34 | }, 35 | { message: "Select type requires 'options' array with at least one item" } 36 | ).refine( 37 | (data) => { 38 | // If min/max provided, type should be 'number' 39 | if ((data.min !== undefined || data.max !== undefined) && data.type !== 'number') { 40 | return false; 41 | } 42 | return true; 43 | }, 44 | { message: "'min' and 'max' are only valid for number type inputs" } 45 | ); 46 | 47 | /** 48 | * Form inputs schema - either legacy string array or new object format 49 | */ 50 | const formInputsSchema = z.union([ 51 | z.array(z.string()), 52 | z.record(z.string(), inputDefinitionSchema), 53 | ]); 54 | 55 | export type InputDefinitionSchema = z.infer; 56 | 57 | // ============================================================================ 58 | // Config Schema (for ~/.mdflow/config.yaml and project configs) 59 | // ============================================================================ 60 | 61 | /** 62 | * Command defaults schema - allows any key that becomes a CLI flag 63 | * Special keys: 64 | * - $1, $2, etc.: Positional argument mappings 65 | * - context_window: Token limit override (number) 66 | * - All other keys: CLI flag values (string, number, boolean, array) 67 | */ 68 | const commandDefaultsSchema = z.record( 69 | z.string(), 70 | z.union([ 71 | z.string(), 72 | z.number(), 73 | z.boolean(), 74 | z.array(z.union([z.string(), z.number(), z.boolean()])), 75 | ]) 76 | ).describe("Command-specific default flags"); 77 | 78 | /** 79 | * Global config schema for config.yaml files 80 | * Structure: 81 | * ```yaml 82 | * commands: 83 | * claude: 84 | * model: sonnet 85 | * print: true 86 | * gemini: 87 | * model: pro 88 | * ``` 89 | */ 90 | export const globalConfigSchema = z.object({ 91 | commands: z.record(z.string(), commandDefaultsSchema).optional(), 92 | }).strict().describe("Global mdflow configuration"); 93 | 94 | /** Type inferred from config schema */ 95 | export type GlobalConfigSchema = z.infer; 96 | 97 | /** 98 | * Validate config.yaml content 99 | * @throws Error with detailed message if validation fails 100 | */ 101 | export function validateConfig(data: unknown): GlobalConfigSchema { 102 | const result = globalConfigSchema.safeParse(data); 103 | 104 | if (!result.success) { 105 | const errors = formatZodIssues(result.error.issues); 106 | throw new Error(`Invalid config.yaml:\n ${errors.join("\n ")}`); 107 | } 108 | 109 | return result.data; 110 | } 111 | 112 | /** 113 | * Validate config without throwing - returns result object 114 | */ 115 | export function safeParseConfig(data: unknown): { 116 | success: boolean; 117 | data?: GlobalConfigSchema; 118 | errors?: string[]; 119 | } { 120 | const result = globalConfigSchema.safeParse(data); 121 | 122 | if (result.success) { 123 | return { success: true, data: result.data }; 124 | } 125 | 126 | const errors = formatZodIssues(result.error.issues); 127 | return { success: false, errors }; 128 | } 129 | 130 | // ============================================================================ 131 | // Frontmatter Schema (for agent .md files) 132 | // ============================================================================ 133 | 134 | /** Main frontmatter schema - minimal, passthrough everything else */ 135 | export const frontmatterSchema = z.object({ 136 | // Form inputs - either legacy string array or new object format with typed definitions 137 | _inputs: formInputsSchema.optional(), 138 | 139 | // Environment variables (underscore-prefixed system key) 140 | // Object form sets process.env 141 | _env: z.record(z.string(), stringCoerce).optional(), 142 | }).passthrough(); // Allow all other keys - they become CLI flags (including $1, $2, etc.) 143 | 144 | /** Type inferred from schema */ 145 | export type FrontmatterSchema = z.infer; 146 | 147 | /** 148 | * Format zod issues into readable error strings 149 | */ 150 | function formatZodIssues(issues: Array<{ path: PropertyKey[]; message: string }>): string[] { 151 | return issues.map(issue => { 152 | const path = issue.path.map(String).join("."); 153 | return path ? `${path}: ${issue.message}` : issue.message; 154 | }); 155 | } 156 | 157 | /** 158 | * Validate parsed YAML against frontmatter schema 159 | */ 160 | export function validateFrontmatter(data: unknown): FrontmatterSchema { 161 | const result = frontmatterSchema.safeParse(data); 162 | 163 | if (!result.success) { 164 | const errors = formatZodIssues(result.error.issues); 165 | throw new Error(`Invalid frontmatter:\n ${errors.join("\n ")}`); 166 | } 167 | 168 | return result.data; 169 | } 170 | 171 | /** 172 | * Validate without throwing - returns result object 173 | */ 174 | export function safeParseFrontmatter(data: unknown): { 175 | success: boolean; 176 | data?: FrontmatterSchema; 177 | errors?: string[]; 178 | } { 179 | const result = frontmatterSchema.safeParse(data); 180 | 181 | if (result.success) { 182 | return { success: true, data: result.data }; 183 | } 184 | 185 | const errors = formatZodIssues(result.error.issues); 186 | return { success: false, errors }; 187 | } 188 | --------------------------------------------------------------------------------