Skip to content

Commit 660a8fb

Browse files
committed
feat: print mode by default with $interactive and .i. filename support
- All CLI tools now default to print/non-interactive mode - Add $interactive frontmatter field to enable interactive mode - Add .i. filename marker (e.g., task.i.claude.md) for interactive mode - Per-command behavior: - claude: --print (default) vs no flag (interactive) - copilot: --prompt (default) vs --interactive - codex: exec subcommand (default) vs no subcommand (interactive) - gemini: one-shot (default) vs --prompt-interactive - Update README and CLAUDE.md with new documentation
1 parent 2910b4b commit 660a8fb

File tree

7 files changed

+184
-28
lines changed

7 files changed

+184
-28
lines changed

CLAUDE.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,24 +41,26 @@ bun run ma task.claude.md
4141
```
4242
.md file → parseFrontmatter() → resolveCommand(filename/env)
4343
→ loadGlobalConfig() → applyDefaults()
44-
expandImports() → substituteTemplateVars()
45-
→ buildArgs() → runCommand()
44+
applyInteractiveMode() → expandImports()
45+
substituteTemplateVars() → buildArgs() → runCommand()
4646
```
4747

4848
### Key Modules
4949

5050
- **`command.ts`** - Command resolution and execution
5151
- `parseCommandFromFilename()`: Infers command from `task.claude.md``claude`
52+
- `hasInteractiveMarker()`: Detects `.i.` in filename (e.g., `task.i.claude.md`)
5253
- `resolveCommand()`: Priority: MA_COMMAND env var > filename
5354
- `buildArgs()`: Converts frontmatter to CLI flags
5455
- `extractPositionalMappings()`: Extracts $1, $2, etc. mappings
5556
- `runCommand()`: Spawns the command with positional args
5657

5758
- **`config.ts`** - Global configuration
5859
- Loads defaults from `~/.markdown-agent/config.yaml`
59-
- Built-in defaults: copilot maps $1 → prompt
60+
- Built-in defaults: All commands default to print mode
6061
- `getCommandDefaults()`: Get defaults for a command
6162
- `applyDefaults()`: Merge defaults with frontmatter
63+
- `applyInteractiveMode()`: Converts print defaults to interactive mode per command
6264

6365
- **`types.ts`** - Core TypeScript interfaces
6466
- `AgentFrontmatter`: Simple interface with system keys + passthrough
@@ -92,6 +94,8 @@ Commands are resolved in priority order:
9294
- `args`: Named positional arguments for template vars
9395
- `env` (object form): Sets process.env before execution
9496
- `$1`, `$2`, etc.: Map positional args to flags
97+
- `$interactive`: Enable interactive mode (overrides print-mode defaults)
98+
- `$exec`: Used internally for codex exec subcommand
9599

96100
**All other keys** are passed directly as CLI flags:
97101

@@ -117,14 +121,27 @@ $1: prompt # Body passed as --prompt <body> instead of positional
117121
---
118122
```
119123

124+
### Print vs Interactive Mode
125+
126+
All commands default to **print mode** (non-interactive). Use `.i.` filename marker or `$interactive: true` for interactive mode.
127+
128+
```bash
129+
task.claude.md # Print mode: claude --print "..."
130+
task.i.claude.md # Interactive: claude "..."
131+
task.copilot.md # Print mode: copilot --silent --prompt "..."
132+
task.i.copilot.md # Interactive: copilot --silent --interactive "..."
133+
task.codex.md # Print mode: codex exec "..."
134+
task.i.codex.md # Interactive: codex "..."
135+
task.gemini.md # Print mode: gemini "..." (one-shot)
136+
task.i.gemini.md # Interactive: gemini --prompt-interactive "..."
137+
```
138+
120139
### Global Config (`~/.markdown-agent/config.yaml`)
121140

122141
Set default frontmatter per command:
123142

124143
```yaml
125144
commands:
126-
copilot:
127-
$1: prompt # Always map body to --prompt for copilot
128145
claude:
129146
model: sonnet # Default model for claude
130147
```

README.md

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Name your file `task.COMMAND.md` and the command is inferred:
4141
task.claude.md # Runs claude
4242
task.gemini.md # Runs gemini
4343
task.codex.md # Runs codex
44-
task.copilot.md # Runs copilot (body auto-mapped to --prompt)
44+
task.copilot.md # Runs copilot (print mode by default)
4545
```
4646

4747
### 2. Frontmatter → CLI Flags
@@ -195,19 +195,55 @@ p: true # → -p (single char = short flag)
195195

196196
---
197197

198+
## Print vs Interactive Mode
199+
200+
All commands run in **print mode by default** (non-interactive, exit after completion). Use the `.i.` filename marker or `$interactive: true` to enable interactive mode.
201+
202+
### Print Mode (Default)
203+
204+
```bash
205+
task.claude.md # Runs: claude --print "..."
206+
task.copilot.md # Runs: copilot --silent --prompt "..."
207+
task.codex.md # Runs: codex exec "..."
208+
task.gemini.md # Runs: gemini "..." (one-shot)
209+
```
210+
211+
### Interactive Mode
212+
213+
Add `.i.` before the command name in the filename:
214+
215+
```bash
216+
task.i.claude.md # Runs: claude "..." (interactive session)
217+
task.i.copilot.md # Runs: copilot --silent --interactive "..."
218+
task.i.codex.md # Runs: codex "..." (interactive session)
219+
task.i.gemini.md # Runs: gemini --prompt-interactive "..."
220+
```
221+
222+
Or use `$interactive: true` in frontmatter:
223+
224+
```yaml
225+
---
226+
$interactive: true
227+
model: opus
228+
---
229+
Review this code with me interactively.
230+
```
231+
232+
---
233+
198234
## Global Configuration
199235

200236
Set default frontmatter per command in `~/.markdown-agent/config.yaml`:
201237

202238
```yaml
203239
commands:
204-
copilot:
205-
$1: prompt # Always map body to --prompt for copilot
206240
claude:
207241
model: sonnet # Default model for claude
242+
copilot:
243+
silent: true # Always use --silent for copilot
208244
```
209245
210-
**Built-in defaults:** Copilot automatically maps `$1: prompt` so you can use it without any frontmatter.
246+
**Built-in defaults:** All commands default to print mode with appropriate flags per CLI tool.
211247
212248
---
213249
@@ -255,7 +291,16 @@ Analyze this codebase and suggest improvements.
255291
Explain this code.
256292
```
257293

258-
Thanks to the global config, this runs: `copilot --prompt "Explain this code."`
294+
This runs: `copilot --silent --prompt "Explain this code."` (print mode)
295+
296+
For interactive mode, use `.i.` in the filename:
297+
298+
```markdown
299+
# task.i.copilot.md
300+
Explain this code.
301+
```
302+
303+
This runs: `copilot --silent --interactive "Explain this code."`
259304

260305
### Template Variables with Args
261306

src/command.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,26 @@ function isPositionalKey(key: string): boolean {
5959
* Extract command from filename
6060
* e.g., "commit.claude.md" → "claude"
6161
* e.g., "task.gemini.md" → "gemini"
62+
* e.g., "fix.i.claude.md" → "claude" (with interactive mode)
6263
*/
6364
export function parseCommandFromFilename(filePath: string): string | undefined {
6465
const name = basename(filePath);
65-
// Match pattern: name.command.md
66+
// Match pattern: name.command.md or name.i.command.md
6667
const match = name.match(/\.([^.]+)\.md$/i);
6768
return match?.[1];
6869
}
6970

71+
/**
72+
* Check if filename has .i. marker for interactive mode
73+
* e.g., "fix.i.claude.md" → true
74+
* e.g., "fix.claude.md" → false
75+
*/
76+
export function hasInteractiveMarker(filePath: string): boolean {
77+
const name = basename(filePath);
78+
// Match pattern: name.i.command.md
79+
return /\.i\.[^.]+\.md$/i.test(name);
80+
}
81+
7082
/**
7183
* Resolve command from filename pattern
7284
* Note: --command flag is handled in index.ts before this is called

src/config.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ describe("config", () => {
2222
const config = await loadGlobalConfig();
2323
expect(config.commands).toBeDefined();
2424
expect(config.commands?.copilot).toBeDefined();
25-
expect(config.commands?.copilot.$1).toBe("interactive");
25+
expect(config.commands?.copilot.$1).toBe("prompt"); // Print mode by default
26+
expect(config.commands?.claude?.print).toBe(true); // Print mode by default
27+
expect(config.commands?.codex?.$exec).toBe(true); // Exec mode by default
2628
});
2729

2830
test("getCommandDefaults returns defaults for copilot", async () => {
2931
const defaults = await getCommandDefaults("copilot");
3032
expect(defaults).toBeDefined();
31-
expect(defaults?.$1).toBe("interactive");
33+
expect(defaults?.$1).toBe("prompt"); // Print mode by default
3234
});
3335

3436
test("getCommandDefaults returns undefined for unknown command", async () => {
@@ -184,7 +186,7 @@ describe("loadFullConfig", () => {
184186

185187
test("includes built-in defaults when no project config", async () => {
186188
const config = await loadFullConfig(testDir);
187-
expect(config.commands?.copilot?.$1).toBe("interactive");
189+
expect(config.commands?.copilot?.$1).toBe("prompt"); // Print mode by default
188190
});
189191

190192
test("project config overrides global config", async () => {
@@ -212,7 +214,7 @@ describe("loadFullConfig", () => {
212214

213215
const config = await loadFullConfig(testDir);
214216
// Built-in defaults preserved
215-
expect(config.commands?.copilot?.$1).toBe("interactive");
217+
expect(config.commands?.copilot?.$1).toBe("prompt"); // Print mode by default
216218
// New command added
217219
expect(config.commands?.["my-tool"]?.$1).toBe("body");
218220
expect(config.commands?.["my-tool"]?.verbose).toBe(true);
@@ -229,7 +231,7 @@ describe("loadFullConfig", () => {
229231

230232
const config = await loadFullConfig(testDir);
231233
// Built-in default preserved
232-
expect(config.commands?.copilot?.$1).toBe("interactive");
234+
expect(config.commands?.copilot?.$1).toBe("prompt"); // Print mode by default
233235
// New setting added
234236
expect(config.commands?.copilot?.verbose).toBe(true);
235237
});

src/config.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,77 @@ const PROJECT_CONFIG_NAMES = ["ma.config.yaml", ".markdown-agent.yaml", ".markdo
2525

2626
/**
2727
* Built-in defaults (used when no config file exists)
28+
* All tools default to PRINT mode (non-interactive)
2829
*/
2930
export const BUILTIN_DEFAULTS: GlobalConfig = {
3031
commands: {
3132
copilot: {
32-
$1: "interactive", // Map body to --interactive for copilot
33+
$1: "prompt", // Map body to --prompt for copilot (print mode)
3334
silent: true, // Output only the agent response (no stats)
3435
},
36+
claude: {
37+
print: true, // --print flag for non-interactive mode
38+
},
39+
codex: {
40+
$exec: true, // Use 'exec' subcommand for non-interactive mode
41+
},
42+
// gemini defaults to one-shot mode (no special flags needed)
3543
},
3644
};
3745

46+
/**
47+
* Apply $interactive mode transformations to frontmatter
48+
* Converts print defaults to interactive mode per command
49+
*
50+
* @param frontmatter - The frontmatter after defaults are applied
51+
* @param command - The resolved command name
52+
* @param interactiveFromFilename - Whether .i. was detected in filename
53+
* @returns Transformed frontmatter for interactive mode
54+
*/
55+
export function applyInteractiveMode(
56+
frontmatter: AgentFrontmatter,
57+
command: string,
58+
interactiveFromFilename: boolean = false
59+
): AgentFrontmatter {
60+
// Check if $interactive is enabled (truthy, empty string, or from filename)
61+
const interactiveMode = frontmatter.$interactive ?? interactiveFromFilename;
62+
if (!interactiveMode && interactiveMode !== "") {
63+
return frontmatter;
64+
}
65+
66+
// Remove $interactive from output (it's a meta-key, not a CLI flag)
67+
const result = { ...frontmatter };
68+
delete result.$interactive;
69+
70+
switch (command) {
71+
case "copilot":
72+
// copilot: Change from --prompt to --interactive
73+
result.$1 = "interactive";
74+
break;
75+
76+
case "claude":
77+
// claude: Remove --print flag (interactive is default without it)
78+
delete result.print;
79+
break;
80+
81+
case "codex":
82+
// codex: Remove $exec marker (interactive is default without exec subcommand)
83+
delete result.$exec;
84+
break;
85+
86+
case "gemini":
87+
// gemini: Add --prompt-interactive flag
88+
result.$1 = "prompt-interactive";
89+
break;
90+
91+
default:
92+
// Unknown command - just remove $interactive, no other changes
93+
break;
94+
}
95+
96+
return result;
97+
}
98+
3899
let cachedGlobalConfig: GlobalConfig | null = null;
39100
let cachedProjectConfig: { cwd: string; config: GlobalConfig } | null = null;
40101

src/context.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ After import`;
311311

312312
expect(config.commands?.claude?.model).toBe("project-model");
313313
// Built-in default for copilot should still be there
314-
expect(config.commands?.copilot?.$1).toBe("interactive");
314+
expect(config.commands?.copilot?.$1).toBe("prompt"); // Print mode by default
315315
});
316316

317317
it("getCommandDefaultsFromConfig is pure function", () => {

src/index.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { parseFrontmatter } from "./parse";
33
import { parseCliArgs, handleMaCommands } from "./cli";
44
import { substituteTemplateVars, extractTemplateVars } from "./template";
55
import { isRemoteUrl, fetchRemote, cleanupRemote } from "./remote";
6-
import { resolveCommand, buildArgs, runCommand, extractPositionalMappings, extractEnvVars, killCurrentChildProcess } from "./command";
6+
import { resolveCommand, buildArgs, runCommand, extractPositionalMappings, extractEnvVars, killCurrentChildProcess, hasInteractiveMarker } from "./command";
77
import { expandImports, hasImports } from "./imports";
88
import { loadEnvFiles } from "./env";
9-
import { loadGlobalConfig, getCommandDefaults, applyDefaults } from "./config";
9+
import { loadGlobalConfig, getCommandDefaults, applyDefaults, applyInteractiveMode } from "./config";
1010
import { initLogger, getParseLogger, getTemplateLogger, getCommandLogger, getImportLogger, getCurrentLogPath } from "./logger";
1111
import { isDomainTrusted, promptForTrust, addTrustedDomain, extractDomain } from "./trust";
1212
import { dirname, resolve } from "path";
@@ -236,7 +236,13 @@ async function main() {
236236
// Load global config and apply command defaults
237237
await loadGlobalConfig();
238238
const commandDefaults = await getCommandDefaults(command);
239-
const frontmatter = applyDefaults(baseFrontmatter, commandDefaults);
239+
let frontmatter = applyDefaults(baseFrontmatter, commandDefaults);
240+
241+
// Check for .i. interactive marker in filename
242+
const interactiveFromFilename = hasInteractiveMarker(localFilePath);
243+
244+
// Apply $interactive mode transformations (converts print defaults to interactive mode per command)
245+
frontmatter = applyInteractiveMode(frontmatter, command, interactiveFromFilename);
240246

241247
// Extract and apply environment variables (object form) to process.env
242248
// This must happen BEFORE import expansion so !`command` inlines can use them
@@ -377,21 +383,27 @@ async function main() {
377383
console.log("═══════════════════════════════════════════════════════════\n");
378384

379385
// Build final args with positional mappings applied (same as runCommand)
380-
const finalArgs = [...args];
386+
let dryRunArgs = [...args];
387+
388+
// Handle codex $exec: prepend 'exec' to args
389+
if (frontmatter.$exec && command === "codex") {
390+
dryRunArgs = ["exec", ...dryRunArgs];
391+
}
392+
381393
for (let i = 0; i < positionals.length; i++) {
382394
const pos = i + 1;
383395
const value = positionals[i];
384396
if (positionalMappings.has(pos)) {
385397
const flagName = positionalMappings.get(pos)!;
386398
const flag = flagName.length === 1 ? `-${flagName}` : `--${flagName}`;
387-
finalArgs.push(flag, `"${value.replace(/"/g, '\\"')}"`);
399+
dryRunArgs.push(flag, `"${value.replace(/"/g, '\\"')}"`);
388400
} else {
389-
finalArgs.push(`"${value.replace(/"/g, '\\"')}"`);
401+
dryRunArgs.push(`"${value.replace(/"/g, '\\"')}"`);
390402
}
391403
}
392404

393405
console.log("Command:");
394-
console.log(` ${command} ${finalArgs.join(" ")}\n`);
406+
console.log(` ${command} ${dryRunArgs.join(" ")}\n`);
395407

396408
console.log("Final Prompt:");
397409
console.log("───────────────────────────────────────────────────────────");
@@ -443,11 +455,18 @@ async function main() {
443455
}
444456
}
445457

446-
getCommandLogger().info({ command, argsCount: args.length, promptLength: finalBody.length }, "Executing command");
458+
// Handle codex $exec: prepend 'exec' to args (codex exec is the non-interactive subcommand)
459+
let finalCommand = command;
460+
let finalRunArgs = args;
461+
if (frontmatter.$exec && command === "codex") {
462+
finalRunArgs = ["exec", ...args];
463+
}
464+
465+
getCommandLogger().info({ command: finalCommand, argsCount: finalRunArgs.length, promptLength: finalBody.length }, "Executing command");
447466

448467
const runResult = await runCommand({
449-
command,
450-
args,
468+
command: finalCommand,
469+
args: finalRunArgs,
451470
positionals,
452471
positionalMappings,
453472
captureOutput: false,

0 commit comments

Comments
 (0)