|
| 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 | +} |
0 commit comments