import { promises as fs } from "node:fs";
import path from "node:path";
import { parseMarkdownFrontmatter } from "./frontmatter.js";
import type { PluginRegistry } from "./registry.js";
import type { PluginCommandInfo } from "./types.js";
export type MarkdownCommand = {
name: string;
description?: string;
source: "project" | "user";
path: string;
body: string;
};
* 发现 markdown 斜杠命令:`<root>/.meowcode/commands/*.md`,文件名即命令名。
* project 同名覆盖 user;解析失败的文件跳过。
*/
export async function discoverMarkdownCommands(
roots: Array<{ dir: string; source: "project" | "user" }>,
): Promise<MarkdownCommand[]> {
const byName = new Map<string, MarkdownCommand>();
for (const { dir, source } of roots) {
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
continue;
}
for (const fileName of entries) {
if (!fileName.toLowerCase().endsWith(".md")) continue;
const full = path.join(dir, fileName);
try {
const info = await fs.stat(full);
if (!info.isFile()) continue;
const raw = await fs.readFile(full, "utf8");
const { meta, body } = parseMarkdownFrontmatter(raw);
if (!body) continue;
const name = fileName.slice(0, -3);
if (byName.has(name)) continue;
byName.set(name, {
name,
...(meta.description ? { description: meta.description } : {}),
source,
path: full,
body,
});
} catch {
continue;
}
}
}
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export function expandCommandTemplate(command: MarkdownCommand | { body: string }, args: string): string {
const trimmedArgs = args.trim();
if (command.body.includes("$ARGUMENTS")) {
return command.body.replaceAll("$ARGUMENTS", trimmedArgs);
}
return trimmedArgs ? `${command.body}\n\nArguments: ${trimmedArgs}` : command.body;
}
* 命令索引:markdown 声明式(project>user)+ 插件代码式(registry)。
* resolve("/deploy foo") 返回展开后的 prompt 文本;未命中斜杠命令返回 null(原样透传)。
*/
export type CommandIndex = {
commands(): PluginCommandInfo[];
resolve(text: string): Promise<string | null>;
};
export async function loadCommandIndex(options: {
workspace: string;
homeDir: string;
registry: PluginRegistry;
}): Promise<CommandIndex> {
const markdown = await discoverMarkdownCommands([
{ dir: path.join(options.workspace, ".meowcode", "commands"), source: "project" },
{ dir: path.join(options.homeDir, ".meowcode", "commands"), source: "user" },
]);
const byName = new Map(markdown.map((cmd) => [cmd.name, cmd]));
return {
commands(): PluginCommandInfo[] {
const items: PluginCommandInfo[] = [...byName.values()].map((cmd) => ({
name: cmd.name,
...(cmd.description !== undefined ? { description: cmd.description } : {}),
source: cmd.source,
}));
items.push(...options.registry.listCommands());
return items;
},
async resolve(text: string): Promise<string | null> {
const trimmed = text.trimStart();
if (!trimmed.startsWith("/")) return null;
const spaceIndex = trimmed.indexOf(" ");
const name = spaceIndex < 0 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
if (!name) return null;
const args = spaceIndex < 0 ? "" : trimmed.slice(spaceIndex + 1);
const mdCommand = byName.get(name);
if (mdCommand) return expandCommandTemplate(mdCommand, args);
const codeCommand = options.registry.getCommand(name);
if (codeCommand) {
const result = await codeCommand.cmd.run(args);
return result;
}
return null;
},
};
}