@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
121 lines (120 loc) • 4.7 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
const CATEGORY_BY_MODULE = {
"search": "Search and browse",
"tasks": "Task management",
"finding": "Finding capture",
"notes": "Notes",
"memory": "Memory quality",
"data": "Data management",
"graph": "Fragments and graph",
"session": "Session management",
"ops": "Operations and review",
"skills": "Skills management",
"hooks": "Hooks management",
"config": "Configuration",
"extract": "Extraction",
};
const MODULE_ORDER = Object.keys(CATEGORY_BY_MODULE);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function decodeStringLiteral(raw) {
return raw
.replace(/\\"/g, "\"")
.replace(/\\'/g, "'")
.replace(/\\n/g, "\n")
.replace(/\\\\/g, "\\");
}
function sourceDir() {
return __dirname;
}
function parseModuleTools(moduleName, source) {
const tools = [];
const pattern = /registerTool\(\s*"([^"]+)"\s*,\s*\{([\s\S]*?)\}\s*,/g;
for (const match of source.matchAll(pattern)) {
const [, name, configBlock] = match;
const descMatch = configBlock.match(/description:\s*"((?:[^"\\]|\\.)*)"/s);
if (!descMatch)
continue;
const titleMatch = configBlock.match(/title:\s*"((?:[^"\\]|\\.)*)"/s);
tools.push({
name,
title: titleMatch ? decodeStringLiteral(titleMatch[1]) : undefined,
description: decodeStringLiteral(descMatch[1]).replace(/\s+/g, " ").trim(),
module: moduleName,
category: CATEGORY_BY_MODULE[moduleName] || "Other",
});
}
// Handle loop-registered tools: { tool: "name", ... } patterns followed by registerTool(action.tool, ...)
const loopPattern = /for\s*\(const\s+(\w+)\s+of\s+\[([\s\S]*?)\]\s*(?:as\s+const\s*)?\)\s*\{[\s\S]*?server\.registerTool\(\s*\1\.tool\s*,\s*\{([\s\S]*?)\}\s*,/g;
for (const loopMatch of source.matchAll(loopPattern)) {
const [, , itemsBlock, configBlock] = loopMatch;
const itemPattern = /\{\s*tool:\s*"([^"]+)"[^}]*verb:\s*"([^"]+)"/g;
for (const itemMatch of itemsBlock.matchAll(itemPattern)) {
const [, toolName, verb] = itemMatch;
const descMatch = configBlock.match(/description:\s*`\$\{[\w.]+\}\s*(.*?)`/s);
const desc = descMatch ? `${verb} ${descMatch[1]}`.trim() : `${verb} operation`;
tools.push({
name: toolName,
description: desc,
module: moduleName,
category: CATEGORY_BY_MODULE[moduleName] || "Other",
});
}
}
return tools;
}
export function getRegisteredTools() {
const dir = sourceDir();
const entries = [];
const toolsDir = path.join(dir, "tools");
for (const moduleName of MODULE_ORDER) {
// Tool files may live in the tools/ subdirectory or alongside tool-registry.ts
let sourcePath;
for (const base of [toolsDir, dir]) {
const tsPath = path.join(base, `${moduleName}.ts`);
const jsPath = path.join(base, `${moduleName}.js`);
if (fs.existsSync(tsPath)) {
sourcePath = tsPath;
break;
}
if (fs.existsSync(jsPath)) {
sourcePath = jsPath;
break;
}
}
if (!sourcePath)
continue;
entries.push(...parseModuleTools(moduleName, fs.readFileSync(sourcePath, "utf8")));
}
return entries;
}
export function getToolCount() {
return getRegisteredTools().length;
}
export function getToolsByCategory() {
const grouped = new Map();
for (const tool of getRegisteredTools()) {
const items = grouped.get(tool.category) || [];
items.push(tool);
grouped.set(tool.category, items);
}
return MODULE_ORDER
.map((moduleName) => CATEGORY_BY_MODULE[moduleName])
.filter((category, index, all) => all.indexOf(category) === index)
.map((category) => ({
category,
tools: (grouped.get(category) || []).sort((a, b) => a.name.localeCompare(b.name)),
}))
.filter((entry) => entry.tools.length > 0);
}
export function renderToolCatalogMarkdown(allowed) {
return getToolsByCategory()
.map(entry => ({ ...entry, tools: entry.tools.filter(tool => !allowed || allowed.has(tool.name)) }))
.filter(entry => entry.tools.length > 0)
.map(({ category, tools }) => {
const lines = tools.map((tool) => `- \`${tool.name}\`: ${tool.description}`);
return `**${category}:**\n${lines.join("\n")}`;
})
.join("\n\n");
}