Skip to content

Commit 0cf60d1

Browse files
committed
feat: add ma create command for interactive agent creation
Adds a new `ma create` subcommand that provides an interactive wizard for creating new markdown agent files. Supports both interactive and non-interactive modes with options for location (-p project, -g global, -l cwd) and frontmatter passthrough (--model, etc.).
1 parent 5ceaef6 commit 0cf60d1

3 files changed

Lines changed: 271 additions & 0 deletions

File tree

src/cli.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,20 @@ export function parseCliArgs(argv: string[]): CliArgs {
7878
function printHelp() {
7979
console.log(`
8080
Usage: ma <file.md> [any flags for the command]
81+
ma create [name] [flags]
8182
ma <file.md> --command <cmd>
8283
ma <file.md> --dry-run
8384
ma <url> [--trust]
8485
ma --setup
8586
ma --logs
8687
ma --help
8788
89+
Create Mode:
90+
ma create Interactive agent creator
91+
ma create --name my-task Create with name
92+
ma create -n task -p Create in project .ma/ folder
93+
ma create -g --model gpt-4 Create in global ~/.ma/ folder with frontmatter
94+
8895
Command resolution:
8996
1. --command flag (e.g., ma task.md --command claude)
9097
2. Filename pattern (e.g., task.claude.md → claude)

src/create.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
/**
2+
* Interactive command to create new agent files
3+
* Usage: ma create [options]
4+
*/
5+
6+
import { input, select, confirm } from "@inquirer/prompts";
7+
import { existsSync, mkdirSync } from "fs";
8+
import { join, resolve } from "path";
9+
import yaml from "js-yaml";
10+
import { getProjectAgentsDir, getUserAgentsDir } from "./cli";
11+
12+
interface CreateOptions {
13+
name?: string;
14+
command?: string;
15+
location?: "cwd" | "project" | "user" | "custom";
16+
customDir?: string;
17+
content?: string;
18+
frontmatter: Record<string, unknown>;
19+
}
20+
21+
/**
22+
* Parse CLI args into create options and frontmatter
23+
*/
24+
function parseCreateArgs(args: string[]): CreateOptions {
25+
const options: CreateOptions = {
26+
frontmatter: {},
27+
};
28+
29+
for (let i = 0; i < args.length; i++) {
30+
const arg = args[i];
31+
32+
// Handle positional arg as name if it's the first arg and not a flag
33+
if (i === 0 && !arg.startsWith("-")) {
34+
options.name = arg;
35+
continue;
36+
}
37+
38+
if (!arg.startsWith("-")) continue;
39+
40+
// Handle known flags
41+
if (arg === "--name" || arg === "-n") {
42+
options.name = args[++i];
43+
} else if (arg === "--command" || arg === "-c") {
44+
options.command = args[++i];
45+
} else if (arg === "--location" || arg === "-l") {
46+
const loc = args[++i];
47+
if (["cwd", "project", "user"].includes(loc)) {
48+
options.location = loc as "cwd" | "project" | "user";
49+
}
50+
} else if (arg === "--dir" || arg === "-d") {
51+
options.location = "custom";
52+
options.customDir = args[++i];
53+
} else if (arg === "--content" || arg === "--body") {
54+
options.content = args[++i];
55+
} else if (arg === "--global" || arg === "-g") {
56+
options.location = "user";
57+
} else if (arg === "--project" || arg === "-p") {
58+
options.location = "project";
59+
} else {
60+
// Treat as frontmatter
61+
const key = arg.replace(/^-+/, "");
62+
// If next arg is a value (not a flag), use it. Otherwise true.
63+
const nextArg = args[i + 1];
64+
65+
if (nextArg && !nextArg.startsWith("-")) {
66+
// Simple type inference for cleaner YAML
67+
if (nextArg === "true") options.frontmatter[key] = true;
68+
else if (nextArg === "false") options.frontmatter[key] = false;
69+
else if (!isNaN(Number(nextArg)) && nextArg.trim() !== "")
70+
options.frontmatter[key] = Number(nextArg);
71+
else options.frontmatter[key] = nextArg;
72+
i++;
73+
} else {
74+
options.frontmatter[key] = true;
75+
}
76+
}
77+
}
78+
79+
return options;
80+
}
81+
82+
/**
83+
* Run the create wizard
84+
*/
85+
export async function runCreate(args: string[]): Promise<void> {
86+
// Handle help flag specially
87+
if (args.includes("--help") || args.includes("-h")) {
88+
console.log(`
89+
Usage: ma create [name] [flags]
90+
91+
Create a new markdown agent. Any unknown flags are added to frontmatter.
92+
93+
Options:
94+
--name, -n Agent name (e.g. 'task')
95+
--command, -c Tool to run (claude, gpt, python, etc)
96+
--project, -p Save to project agents (.ma/)
97+
--global, -g Save to global agents (~/.ma/)
98+
--content Initial prompt content
99+
100+
Examples:
101+
ma create task --command claude
102+
ma create search -g --model perplexity
103+
`);
104+
return;
105+
}
106+
107+
const options = parseCreateArgs(args);
108+
109+
// 1. Name
110+
if (!options.name) {
111+
options.name = await input({
112+
message: "Agent name:",
113+
default: "task.claude.md",
114+
validate: (value) => value.length > 0 || "Name is required",
115+
});
116+
}
117+
118+
// Ensure extension
119+
if (!options.name.endsWith(".md")) {
120+
options.name += ".md";
121+
}
122+
123+
// 2. Command (if not specified and not obvious from name)
124+
const nameParts = options.name.split(".");
125+
// Matches name.command.md pattern
126+
const inferredCommand =
127+
nameParts.length >= 3 ? nameParts[nameParts.length - 2] : undefined;
128+
129+
if (!options.command && !inferredCommand) {
130+
// Check if frontmatter has a model, often implies a specific tool
131+
const defaultCmd = options.frontmatter.model ? "claude" : "claude";
132+
133+
options.command = await input({
134+
message: "Command to wrap (e.g. claude, python, bash):",
135+
default: defaultCmd,
136+
});
137+
}
138+
139+
// 3. Location
140+
if (!options.location) {
141+
options.location = await select({
142+
message: "Where should we create this agent?",
143+
choices: [
144+
{ name: `Current Directory (${process.cwd()})`, value: "cwd" },
145+
{
146+
name: "Project (.ma/)",
147+
value: "project",
148+
description: "Shared with team",
149+
},
150+
{
151+
name: "User (~/.ma/)",
152+
value: "user",
153+
description: "Personal global agents",
154+
},
155+
],
156+
});
157+
}
158+
159+
// Determine target directory
160+
let targetDir = process.cwd();
161+
if (options.location === "project") {
162+
targetDir = getProjectAgentsDir();
163+
} else if (options.location === "user") {
164+
targetDir = getUserAgentsDir();
165+
} else if (options.location === "custom" && options.customDir) {
166+
targetDir = resolve(options.customDir);
167+
}
168+
169+
// 4. Content
170+
if (options.content === undefined) {
171+
// Default template
172+
options.content = `
173+
# ${options.name.replace(".md", "")}
174+
175+
Describe your agent's task here.
176+
You can use variables like {{ prompt }} which map to CLI arguments.
177+
178+
Example:
179+
Can you help me with {{ prompt }}?
180+
`.trim();
181+
}
182+
183+
// Prepare frontmatter
184+
const finalFrontmatter = { ...options.frontmatter };
185+
186+
// If no args defined and using default template, add safe defaults
187+
if (!finalFrontmatter.args && !Object.keys(options.frontmatter).length) {
188+
finalFrontmatter.args = ["prompt"];
189+
}
190+
191+
// Construct YAML
192+
let fileContent = "";
193+
if (Object.keys(finalFrontmatter).length > 0) {
194+
fileContent += "---\n";
195+
fileContent += yaml.dump(finalFrontmatter);
196+
fileContent += "---\n\n";
197+
}
198+
199+
fileContent += options.content;
200+
201+
// Append newline
202+
if (!fileContent.endsWith("\n")) {
203+
fileContent += "\n";
204+
}
205+
206+
// Ensure directory exists
207+
if (!existsSync(targetDir)) {
208+
mkdirSync(targetDir, { recursive: true });
209+
}
210+
211+
const targetPath = join(targetDir, options.name);
212+
213+
if (existsSync(targetPath)) {
214+
const overwrite = await confirm({
215+
message: `File ${options.name} already exists in ${targetDir}. Overwrite?`,
216+
default: false,
217+
});
218+
if (!overwrite) {
219+
console.log("Cancelled.");
220+
return;
221+
}
222+
}
223+
224+
await Bun.write(targetPath, fileContent);
225+
226+
console.log(`\n✅ Created agent: ${targetPath}`);
227+
228+
// Suggest renaming if command isn't embedded and wasn't explicit
229+
if (
230+
options.command &&
231+
!inferredCommand &&
232+
!options.name.includes(`.${options.command}.`)
233+
) {
234+
console.log(
235+
`\nTip: Consider naming your file "${options.name.replace(".md", "")}.${options.command}.md" to auto-detect the command.`
236+
);
237+
}
238+
239+
// Show run command
240+
const relativePath =
241+
options.location === "cwd" ? `./${options.name}` : options.name;
242+
let runCmd = `ma ${relativePath}`;
243+
244+
// If we had to supply a command explicitly and it's not in the filename
245+
if (
246+
options.command &&
247+
!inferredCommand &&
248+
!options.name.includes(`.${options.command}.`)
249+
) {
250+
runCmd += ` --command ${options.command}`;
251+
}
252+
253+
console.log(`\nRun it with:\n ${runCmd}\n`);
254+
}

src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ async function main() {
7979
try {
8080
const cliArgs = parseCliArgs(process.argv);
8181

82+
// Handle 'create' subcommand
83+
// Intercept "create" as a keyword rather than a file path
84+
if (cliArgs.filePath === "create") {
85+
// Dynamic import to avoid loading inquirer prompts when not needed (startup speed)
86+
const { runCreate } = await import("./create");
87+
await runCreate(cliArgs.passthroughArgs);
88+
process.exit(0);
89+
}
90+
8291
// Handle ma's own commands when no file provided
8392
let filePath = cliArgs.filePath;
8493
if (!filePath) {
@@ -89,6 +98,7 @@ async function main() {
8998
} else if (!result.handled) {
9099
// No file selected and no command handled - show usage
91100
console.error("Usage: ma <file.md> [flags for command]");
101+
console.error(" ma create [options]");
92102
console.error("Run 'ma --help' for more info");
93103
throw new ConfigurationError("No agent file specified", 1);
94104
}

0 commit comments

Comments
 (0)