Skip to content

Commit 79332b5

Browse files
committed
fix: run inline commands in invocation directory, add _cwd override
Inline commands (!`cmd`) now run in the user's working directory instead of the agent file's directory. This fixes the issue where agents in ~/.ma would execute commands in ~/.ma rather than the project directory. Added _cwd frontmatter key and --_cwd CLI flag to override the command working directory. Priority: CLI --_cwd > frontmatter _cwd > process.cwd()
1 parent abc2085 commit 79332b5

File tree

4 files changed

+98
-3
lines changed

4 files changed

+98
-3
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ Commands are resolved in priority order:
9696
- `$1`, `$2`, etc.: Map positional args to flags
9797
- `_interactive`: Enable interactive mode (overrides print-mode defaults)
9898
- `_subcommand`: Prepend subcommand(s) to CLI args (e.g., `_subcommand: exec`)
99+
- `_cwd`: Override working directory for inline commands (`` !`cmd` ``)
99100

100101
**All other keys** are passed directly as CLI flags:
101102

src/imports.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,71 @@ test("expandImports allows same content via different files (not symlinks)", asy
354354
// Should work fine - not a cycle
355355
expect(result).toBe("Same content Same content");
356356
});
357+
358+
// Command cwd tests
359+
test("expandImports runs commands in invocationCwd, not file directory", async () => {
360+
// Create a separate directory to simulate the agent file location
361+
const agentDir = join(testDir, "agent-dir");
362+
await Bun.write(join(agentDir, "dummy.md"), ""); // ensure dir exists
363+
364+
// Create another directory to simulate the invocation directory
365+
const invocationDir = join(testDir, "invocation-dir");
366+
await Bun.write(join(invocationDir, "dummy.md"), ""); // ensure dir exists
367+
368+
// Command that outputs the current working directory
369+
const content = "!`pwd`";
370+
371+
// When invocationCwd is set, commands should run in that directory
372+
const result = await expandImports(content, agentDir, new Set(), false, {
373+
invocationCwd: invocationDir,
374+
});
375+
376+
// The pwd output should be the invocation directory, not the agent directory
377+
expect(result).toContain(invocationDir);
378+
expect(result).not.toContain("agent-dir");
379+
});
380+
381+
test("expandImports uses file directory for commands when invocationCwd not set", async () => {
382+
// This tests backward compatibility - when invocationCwd is not provided,
383+
// commands should still run in the file's directory (current behavior)
384+
const content = "!`pwd`";
385+
const result = await expandImports(content, testDir);
386+
387+
// Should contain the testDir path
388+
expect(result).toContain(testDir.split("/").pop());
389+
});
390+
391+
test("expandImports allows _cwd override via ImportContext", async () => {
392+
// Test that invocationCwd can be explicitly set to override where commands run
393+
const customDir = join(testDir, "custom-cwd");
394+
await Bun.write(join(customDir, "dummy.md"), ""); // ensure dir exists
395+
396+
const content = "!`pwd`";
397+
const result = await expandImports(content, testDir, new Set(), false, {
398+
invocationCwd: customDir,
399+
});
400+
401+
// The command should run in customDir, not testDir
402+
expect(result).toContain("custom-cwd");
403+
});
404+
405+
test("expandImports runs bun commands in invocationCwd", async () => {
406+
// Test using bun's process.cwd() to verify the working directory
407+
// This ensures the cwd is properly passed to spawned processes
408+
const agentDir = join(testDir, "bun-agent-dir");
409+
await Bun.write(join(agentDir, "dummy.md"), ""); // ensure dir exists
410+
411+
const invocationDir = join(testDir, "bun-invocation-dir");
412+
await Bun.write(join(invocationDir, "dummy.md"), ""); // ensure dir exists
413+
414+
// Use bun to check process.cwd()
415+
const content = '!`bun -e "console.log(process.cwd())"`';
416+
417+
const result = await expandImports(content, agentDir, new Set(), false, {
418+
invocationCwd: invocationDir,
419+
});
420+
421+
// The bun process should report the invocation directory as cwd
422+
expect(result).toContain("bun-invocation-dir");
423+
expect(result).not.toContain("bun-agent-dir");
424+
});

src/imports.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export interface ImportContext {
5252
env?: Record<string, string | undefined>;
5353
/** Track resolved imports for ExecutionPlan */
5454
resolvedImports?: ResolvedImportsTracker;
55+
/**
56+
* Working directory for command execution (!`cmd` inlines).
57+
* When set, commands run in this directory instead of the agent file's directory.
58+
* This allows agents in ~/.ma to execute commands in the user's invocation directory.
59+
*/
60+
invocationCwd?: string;
5561
}
5662

5763
/**
@@ -749,9 +755,13 @@ async function processCommandInline(
749755
// Use importCtx.env if provided, otherwise fall back to process.env
750756
const env = importCtx?.env ?? process.env;
751757

758+
// Use invocationCwd for command execution if provided (allows agents in ~/.ma
759+
// to run commands in the user's current directory), fall back to file directory
760+
const commandCwd = importCtx?.invocationCwd ?? currentFileDir;
761+
752762
try {
753763
const result = Bun.spawnSync(["sh", "-c", command], {
754-
cwd: currentFileDir,
764+
cwd: commandCwd,
755765
stdout: "pipe",
756766
stderr: "pipe",
757767
env: env as Record<string, string>,

src/index.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,14 @@ async function main() {
226226
remainingArgs.splice(interactiveIndex, 1); // Consume it
227227
}
228228

229+
// Check for --_cwd flag (consumed by ma, overrides working directory for !`cmd` inlines)
230+
let cwdFromCli: string | undefined;
231+
const cwdFlagIndex = remainingArgs.findIndex(arg => arg === "--_cwd");
232+
if (cwdFlagIndex !== -1 && cwdFlagIndex + 1 < remainingArgs.length) {
233+
cwdFromCli = remainingArgs[cwdFlagIndex + 1];
234+
remainingArgs.splice(cwdFlagIndex, 2); // Consume --_cwd and its value
235+
}
236+
229237
// Resolve command: CLI --command > MA_COMMAND env > filename
230238
let command: string;
231239
try {
@@ -319,8 +327,16 @@ async function main() {
319327

320328
if (hasImports(rawBody)) {
321329
try {
322-
getImportLogger().debug({ fileDir }, "Expanding imports");
323-
expandedBody = await expandImports(rawBody, fileDir, new Set());
330+
// Determine working directory for !`cmd` inlines:
331+
// Priority: CLI --_cwd > frontmatter _cwd > process.cwd()
332+
const commandCwd = cwdFromCli
333+
?? (frontmatter._cwd as string | undefined)
334+
?? process.cwd();
335+
336+
getImportLogger().debug({ fileDir, commandCwd }, "Expanding imports");
337+
expandedBody = await expandImports(rawBody, fileDir, new Set(), false, {
338+
invocationCwd: commandCwd,
339+
});
324340
getImportLogger().debug({ originalLength: rawBody.length, expandedLength: expandedBody.length }, "Imports expanded");
325341
} catch (err) {
326342
getImportLogger().error({ error: (err as Error).message }, "Import expansion failed");

0 commit comments

Comments
 (0)