Skip to content

Commit 24d2028

Browse files
committed
feat: add --command flag hijacking for generic markdown files
- Add --command / -c flag to override command resolution - Priority: --command > MA_COMMAND env > filename pattern - Flag is consumed (not passed to underlying command) - Allows generic .md files without command in filename - Document flag hijacking in README (--command and $varname) - Add tests for flag hijacking behavior
1 parent 41ae1b9 commit 24d2028

3 files changed

Lines changed: 115 additions & 8 deletions

File tree

README.md

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,55 @@ ma task.claude.md --verbose --debug
114114

115115
Commands are resolved in this priority order:
116116

117-
1. **Environment variable**: `MA_COMMAND=claude`
118-
2. **Filename pattern**: `task.claude.md``claude`
117+
1. **CLI flag**: `--command claude` or `-c claude`
118+
2. **Environment variable**: `MA_COMMAND=claude`
119+
3. **Filename pattern**: `task.claude.md``claude`
119120

120121
If no command can be resolved, you'll get an error with instructions.
121122

122123
---
123124

125+
## Flag Hijacking
126+
127+
Some CLI flags are "hijacked" by markdown-agent—they're consumed and never passed to the underlying command. This allows generic markdown files without command names to be executed.
128+
129+
### `--command` / `-c`
130+
131+
Override the command for any markdown file:
132+
133+
```bash
134+
# Run a generic .md file with any command
135+
ma task.md --command claude
136+
ma task.md -c gemini
137+
138+
# Override the filename-inferred command
139+
ma task.claude.md --command gemini # Runs gemini, not claude
140+
```
141+
142+
### `$varname` Fields
143+
144+
Frontmatter fields starting with `$` (except `$1`, `$2`...) hijack their corresponding CLI flags:
145+
146+
```yaml
147+
---
148+
$feature_name: Authentication # Default value
149+
$target_dir: src/features # Default value
150+
---
151+
Build {{ feature_name }} in {{ target_dir }}.
152+
```
153+
154+
```bash
155+
# Use defaults
156+
ma create.claude.md
157+
158+
# Override with CLI flags (hijacked, not passed to command)
159+
ma create.claude.md --feature_name "Payments" --target_dir "src/billing"
160+
```
161+
162+
The `--feature_name` and `--target_dir` flags are consumed by markdown-agent for template substitution—they won't be passed to the command.
163+
164+
---
165+
124166
## Frontmatter Reference
125167

126168
### System Keys (handled by markdown-agent)
@@ -377,13 +419,15 @@ Environment variables are available:
377419

378420
```
379421
Usage: ma <file.md> [any flags for the command]
422+
ma <file.md> --command <cmd>
380423
ma --setup
381424
ma --logs
382425
ma --help
383426
384427
Command resolution:
385-
1. MA_COMMAND env var (e.g., MA_COMMAND=claude ma task.md)
386-
2. Filename pattern (e.g., task.claude.md → claude)
428+
1. --command flag (e.g., ma task.md --command claude)
429+
2. MA_COMMAND env var (e.g., MA_COMMAND=claude ma task.md)
430+
3. Filename pattern (e.g., task.claude.md → claude)
387431
388432
All frontmatter keys are passed as CLI flags to the command.
389433
Global defaults can be set in ~/.markdown-agent/config.yaml
@@ -392,6 +436,8 @@ Examples:
392436
ma task.claude.md -p "print mode"
393437
ma task.claude.md --model opus --verbose
394438
ma commit.gemini.md
439+
ma task.md --command claude
440+
ma task.md -c gemini
395441
MA_COMMAND=claude ma task.md
396442
397443
Without a file:

src/args.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, test, describe } from "bun:test";
22
import { parseFrontmatter } from "./parse";
33
import { substituteTemplateVars, extractTemplateVars } from "./template";
4+
import { resolveCommand } from "./command";
45

56
/**
67
* Tests for the args system:
@@ -168,3 +169,48 @@ Build {{ feature_name }}`;
168169
expect(result).toBe("Build Payments");
169170
});
170171
});
172+
173+
describe("flag hijacking", () => {
174+
test("--command flag is consumed and used as command", () => {
175+
// Simulate CLI: ma generic.md --command claude --model opus
176+
const cliArgs = ["--command", "claude", "--model", "opus"];
177+
178+
// Extract --command flag (same logic as index.ts)
179+
let commandFromCli: string | undefined;
180+
const remainingArgs = [...cliArgs];
181+
182+
const commandFlagIndex = remainingArgs.findIndex(arg => arg === "--command" || arg === "-c");
183+
if (commandFlagIndex !== -1 && commandFlagIndex + 1 < remainingArgs.length) {
184+
commandFromCli = remainingArgs[commandFlagIndex + 1];
185+
remainingArgs.splice(commandFlagIndex, 2);
186+
}
187+
188+
expect(commandFromCli).toBe("claude");
189+
expect(remainingArgs).toEqual(["--model", "opus"]); // --command consumed
190+
});
191+
192+
test("-c short flag also works for command", () => {
193+
const cliArgs = ["-c", "gemini", "--verbose"];
194+
const remainingArgs = [...cliArgs];
195+
196+
let commandFromCli: string | undefined;
197+
const commandFlagIndex = remainingArgs.findIndex(arg => arg === "--command" || arg === "-c");
198+
if (commandFlagIndex !== -1 && commandFlagIndex + 1 < remainingArgs.length) {
199+
commandFromCli = remainingArgs[commandFlagIndex + 1];
200+
remainingArgs.splice(commandFlagIndex, 2);
201+
}
202+
203+
expect(commandFromCli).toBe("gemini");
204+
expect(remainingArgs).toEqual(["--verbose"]);
205+
});
206+
207+
test("--command takes priority over filename", () => {
208+
// If --command is provided, it takes priority
209+
// (filename would give "codex" but --command says "claude")
210+
const commandFromCli = "claude";
211+
const commandFromFilename = "codex"; // from task.codex.md
212+
213+
const command = commandFromCli || commandFromFilename;
214+
expect(command).toBe("claude");
215+
});
216+
});

src/index.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,27 @@ async function main() {
7676
const { frontmatter: baseFrontmatter, body: rawBody } = parseFrontmatter(content);
7777
getParseLogger().debug({ frontmatter: baseFrontmatter, bodyLength: rawBody.length }, "Frontmatter parsed");
7878

79-
// Resolve command (from env var or filename)
79+
// Check for --command flag in CLI args (consumed, not passed to command)
80+
// This allows: ma generic.md --command claude
81+
let remainingArgs = [...passthroughArgs];
82+
let commandFromCli: string | undefined;
83+
84+
const commandFlagIndex = remainingArgs.findIndex(arg => arg === "--command" || arg === "-c");
85+
if (commandFlagIndex !== -1 && commandFlagIndex + 1 < remainingArgs.length) {
86+
commandFromCli = remainingArgs[commandFlagIndex + 1];
87+
remainingArgs.splice(commandFlagIndex, 2); // Consume --command and its value
88+
}
89+
90+
// Resolve command: CLI --command > MA_COMMAND env > filename
8091
let command: string;
8192
try {
82-
command = resolveCommand(localFilePath);
83-
getCommandLogger().debug({ command }, "Command resolved");
93+
if (commandFromCli) {
94+
command = commandFromCli;
95+
getCommandLogger().debug({ command, source: "cli" }, "Command from --command flag");
96+
} else {
97+
command = resolveCommand(localFilePath);
98+
getCommandLogger().debug({ command }, "Command resolved");
99+
}
84100
} catch (err) {
85101
getCommandLogger().error({ error: (err as Error).message }, "Command resolution failed");
86102
console.error((err as Error).message);
@@ -103,7 +119,6 @@ async function main() {
103119

104120
// Consume named positional arguments from CLI
105121
let templateVars: Record<string, string> = {};
106-
let remainingArgs = [...passthroughArgs];
107122

108123
if (frontmatter.args && Array.isArray(frontmatter.args)) {
109124
const requiredArgs = frontmatter.args;

0 commit comments

Comments
 (0)