Skip to content

Commit e99c2f1

Browse files
committed
feat: add .ma/ agent discovery for user and project levels
- Add ~/.ma/ for user-level agents (available everywhere) - Add ./.ma/ for project-level agents - Discovery priority: cwd > ./.ma/ > ~/.ma/ > $PATH - Export getProjectAgentsDir() and getUserAgentsDir() helpers - Update help text with new discovery documentation - Add 6 tests for new discovery functionality
1 parent 717a6fe commit e99c2f1

2 files changed

Lines changed: 152 additions & 9 deletions

File tree

src/cli.test.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { expect, test, describe } from "bun:test";
2-
import { parseCliArgs } from "./cli";
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";
36

47
describe("parseCliArgs", () => {
58
test("extracts file path", () => {
@@ -45,3 +48,82 @@ describe("parseCliArgs", () => {
4548
expect(result.passthroughArgs).toEqual(["--help", "--setup"]);
4649
});
4750
});
51+
52+
describe("agent directory paths", () => {
53+
test("getProjectAgentsDir returns .ma in cwd", () => {
54+
const dir = getProjectAgentsDir();
55+
expect(dir).toBe(join(process.cwd(), ".ma"));
56+
});
57+
58+
test("getUserAgentsDir returns ~/.ma", () => {
59+
const dir = getUserAgentsDir();
60+
expect(dir).toBe(join(homedir(), ".ma"));
61+
});
62+
});
63+
64+
describe("findAgentFiles", () => {
65+
const testProjectMaDir = join(process.cwd(), ".ma-test");
66+
const testUserMaDir = join(homedir(), ".ma-test-user");
67+
68+
beforeEach(() => {
69+
// Create test directories
70+
if (!existsSync(testProjectMaDir)) {
71+
mkdirSync(testProjectMaDir, { recursive: true });
72+
}
73+
});
74+
75+
afterEach(() => {
76+
// Cleanup test directories
77+
if (existsSync(testProjectMaDir)) {
78+
rmSync(testProjectMaDir, { recursive: true, force: true });
79+
}
80+
});
81+
82+
test("finds files from current directory", async () => {
83+
const files = await findAgentFiles();
84+
// Should find .md files in cwd (like CLAUDE.md, README.md, etc.)
85+
const cwdFiles = files.filter(f => f.source === "cwd");
86+
expect(cwdFiles.length).toBeGreaterThan(0);
87+
});
88+
89+
test("finds files from .ma/ directory when present", async () => {
90+
// Create a test .ma directory with a file
91+
const maDir = join(process.cwd(), ".ma");
92+
const testFile = join(maDir, "test-agent.claude.md");
93+
94+
try {
95+
mkdirSync(maDir, { recursive: true });
96+
writeFileSync(testFile, "---\nmodel: opus\n---\nTest agent");
97+
98+
const files = await findAgentFiles();
99+
const maFiles = files.filter(f => f.source === ".ma");
100+
101+
expect(maFiles.length).toBeGreaterThan(0);
102+
expect(maFiles.some(f => f.name === "test-agent.claude.md")).toBe(true);
103+
} finally {
104+
// Cleanup
105+
if (existsSync(testFile)) rmSync(testFile);
106+
if (existsSync(maDir)) rmSync(maDir, { recursive: true, force: true });
107+
}
108+
});
109+
110+
test("deduplicates files by normalized path", async () => {
111+
const files = await findAgentFiles();
112+
const paths = files.map(f => f.path);
113+
const uniquePaths = new Set(paths);
114+
expect(paths.length).toBe(uniquePaths.size);
115+
});
116+
117+
test("returns files with correct structure", async () => {
118+
const files = await findAgentFiles();
119+
if (files.length > 0) {
120+
const file = files[0];
121+
expect(file).toHaveProperty("name");
122+
expect(file).toHaveProperty("path");
123+
expect(file).toHaveProperty("source");
124+
expect(typeof file.name).toBe("string");
125+
expect(typeof file.path).toBe("string");
126+
expect(typeof file.source).toBe("string");
127+
}
128+
});
129+
});

src/cli.ts

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { select } from "@inquirer/prompts";
22
import { Glob } from "bun";
3-
import { basename } from "path";
3+
import { basename, join } from "path";
44
import { realpathSync } from "fs";
5+
import { homedir } from "os";
56

67
export interface CliArgs {
78
filePath: string;
@@ -87,6 +88,13 @@ Command resolution:
8788
1. --command flag (e.g., ma task.md --command claude)
8889
2. Filename pattern (e.g., task.claude.md → claude)
8990
91+
Agent file discovery (in priority order):
92+
1. Explicit path: ma ./path/to/agent.md
93+
2. Current directory: ./
94+
3. Project agents: ./.ma/
95+
4. User agents: ~/.ma/
96+
5. $PATH directories
97+
9098
All frontmatter keys are passed as CLI flags to the command.
9199
Global defaults can be set in ~/.markdown-agent/config.yaml
92100
@@ -116,9 +124,10 @@ ma-specific flags (consumed, not passed to command):
116124
--trust Skip trust prompt for remote URLs (TOFU bypass)
117125
118126
Without a file:
119-
ma --setup Configure shell to run .md files directly
120-
ma --logs Show log directory
121-
ma --help Show this help
127+
ma Interactive agent picker (from ./.ma/, ~/.ma/, etc.)
128+
ma --setup Configure shell to run .md files directly
129+
ma --logs Show log directory
130+
ma --help Show this help
122131
`);
123132
}
124133

@@ -135,9 +144,20 @@ function normalizePath(filePath: string): string {
135144
}
136145
}
137146

147+
/** Project-level agent directory */
148+
const PROJECT_AGENTS_DIR = ".ma";
149+
150+
/** User-level agent directory */
151+
const USER_AGENTS_DIR = join(homedir(), ".ma");
152+
138153
/**
139-
* Find agent markdown files from current directory and $PATH
140-
* Returns files sorted by source (cwd first, then PATH directories)
154+
* Find agent markdown files with priority order:
155+
* 1. Current directory (cwd)
156+
* 2. Project-level: ./.ma/
157+
* 3. User-level: ~/.ma/
158+
* 4. $PATH directories
159+
*
160+
* Returns files sorted by source priority (earlier sources take precedence)
141161
*/
142162
export async function findAgentFiles(): Promise<AgentFile[]> {
143163
const files: AgentFile[] = [];
@@ -158,7 +178,34 @@ export async function findAgentFiles(): Promise<AgentFile[]> {
158178
// Skip if cwd is not accessible
159179
}
160180

161-
// 2. $PATH directories
181+
// 2. Project-level: ./.ma/
182+
const projectAgentsPath = join(process.cwd(), PROJECT_AGENTS_DIR);
183+
try {
184+
for await (const file of glob.scan({ cwd: projectAgentsPath, absolute: true })) {
185+
const normalizedPath = normalizePath(file);
186+
if (!seenPaths.has(normalizedPath)) {
187+
seenPaths.add(normalizedPath);
188+
files.push({ name: basename(file), path: normalizedPath, source: ".ma" });
189+
}
190+
}
191+
} catch {
192+
// Skip if .ma/ doesn't exist
193+
}
194+
195+
// 3. User-level: ~/.ma/
196+
try {
197+
for await (const file of glob.scan({ cwd: USER_AGENTS_DIR, absolute: true })) {
198+
const normalizedPath = normalizePath(file);
199+
if (!seenPaths.has(normalizedPath)) {
200+
seenPaths.add(normalizedPath);
201+
files.push({ name: basename(file), path: normalizedPath, source: "~/.ma" });
202+
}
203+
}
204+
} catch {
205+
// Skip if ~/.ma/ doesn't exist
206+
}
207+
208+
// 4. $PATH directories
162209
const pathDirs = (process.env.PATH || "").split(":");
163210
for (const dir of pathDirs) {
164211
if (!dir) continue;
@@ -178,6 +225,20 @@ export async function findAgentFiles(): Promise<AgentFile[]> {
178225
return files;
179226
}
180227

228+
/**
229+
* Get the project agents directory path
230+
*/
231+
export function getProjectAgentsDir(): string {
232+
return join(process.cwd(), PROJECT_AGENTS_DIR);
233+
}
234+
235+
/**
236+
* Get the user agents directory path
237+
*/
238+
export function getUserAgentsDir(): string {
239+
return USER_AGENTS_DIR;
240+
}
241+
181242
/**
182243
* Show interactive file picker and return selected file path
183244
*/

0 commit comments

Comments
 (0)