├── 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 |
--------------------------------------------------------------------------------