diff --git a/bun.lock b/bun.lock index 917e4ca..1909529 100644 --- a/bun.lock +++ b/bun.lock @@ -1,18 +1,19 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@studio-mcp/studio", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4", - "zod": "^3.23.8", + "@modelcontextprotocol/sdk": "^1.22.0", + "zod": "^3.25.76", }, "devDependencies": { "@modelcontextprotocol/inspector": "^0.17.2", - "@types/node": "^22.10.1", - "tsx": "^4.19.2", - "typescript": "^5.7.2", - "vitest": "^2.1.8", + "@types/node": "^22.19.1", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "vitest": "^2.1.9", }, }, }, diff --git a/src/index.ts b/src/index.ts index 2a6e23f..15b7112 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,12 @@ import { fileURLToPath } from "url"; import { fromArgs } from "./studio/parse.js"; import { generateInputSchema, type Schema } from "./studio/schema.js"; import { buildCommandArgs } from "./studio/render.js"; +import { + loadConfig, + filterCommands, + getDefaultConfigPath, + type StudioConfig, +} from "./studio/config.js"; // Read version from package.json const __filename = fileURLToPath(import.meta.url); @@ -24,12 +30,16 @@ function parseArgs(args: string[]): { debug: boolean; version: boolean; logFile: string; + configPath: string; + commandsFilter: string[]; commandArgs: string[]; } { let i = 0; let debug = false; let version = false; let logFile = ""; + let configPath = ""; + const commandsFilter: string[] = []; const commandArgs: string[] = []; // Parse studio flags until we hit a non-flag or -- @@ -66,17 +76,47 @@ function parseArgs(args: string[]): { } logFile = args[i]; break; + case "--config": + // Check if we have a next argument for the config path + if (i + 1 >= args.length) { + throw new Error("--config requires a path argument"); + } + i++; + // Check if the next argument is another flag + if (args[i].startsWith("-")) { + throw new Error("--config requires a path argument"); + } + configPath = args[i]; + break; + case "--commands": + // Check if we have a next argument for the commands list + if (i + 1 >= args.length) { + throw new Error("--commands requires a comma-separated list"); + } + i++; + // Check if the next argument is another flag + if (args[i].startsWith("-")) { + throw new Error("--commands requires a comma-separated list"); + } + commandsFilter.push( + ...args[i].split(",").map((s) => s.trim()).filter((s) => s.length > 0), + ); + break; case "-h": case "--help": console.log(`studio - One word MCP for any CLI command -Usage: studio [--debug] [--log filename] [--] [args...] +Usage: + studio [--debug] [--log filename] [--] [args...] + studio [--debug] [--log filename] [--config ] [--commands ] Options: -h, --help Show this help message and exit --version Show version information and exit --debug Print debug logs to stderr --log Write debug logs to specified file + --config Load commands from config file (default: ~/.config/studio/config.jsonc) + --commands Comma-separated list of commands to load from config -- End flag parsing, treat rest as command Template Syntax: @@ -88,9 +128,19 @@ Template Syntax: {name#description} Argument with description [-f] Boolean flag (optional) -Example: +Config File Format (.config/studio/config.jsonc): + { + "echo": { + "description": "Run the echo command", // optional + "command": ["echo", "Hello World"] // required + } + } + +Examples: studio echo "{text#message to echo}" studio git "[args...]" + studio --config myconfig.jsonc + studio --commands "echo,git" `); process.exit(0); break; @@ -104,7 +154,7 @@ Example: // Everything from i onwards goes to command template parsing commandArgs.push(...args.slice(i)); - return { debug, version, logFile, commandArgs }; + return { debug, version, logFile, configPath, commandsFilter, commandArgs }; } /** @@ -172,6 +222,58 @@ async function execute(command: string, args: string[]): Promise { }); } +/** + * Register a config-based command as an MCP tool + */ +function registerConfigCommand( + server: McpServer, + name: string, + config: { description?: string; command: string[] }, + debug: boolean, +) { + const toolName = name.replace(/-/g, "_"); + const toolDescription = + config.description || `Run the command: ${config.command.join(" ")}`; + + server.registerTool( + toolName, + { + title: name, + description: toolDescription, + inputSchema: {}, // No parameters for config commands + }, + async () => { + try { + if (debug) { + console.error(`[Studio MCP] Executing: ${config.command.join(" ")}`); + } + + const output = await execute(config.command[0], config.command.slice(1)); + + return { + content: [ + { + type: "text" as const, + text: output, + }, + ], + }; + } catch (err: any) { + const errorMsg = err.message || String(err); + return { + content: [ + { + type: "text" as const, + text: errorMsg, + }, + ], + isError: true, + }; + } + }, + ); +} + /** * Main function */ @@ -179,7 +281,8 @@ async function main() { const args = process.argv.slice(2); try { - const { debug, version, logFile, commandArgs } = parseArgs(args); + const { debug, version, logFile, configPath, commandsFilter, commandArgs } = + parseArgs(args); // Handle version flag if (version) { @@ -189,71 +292,106 @@ async function main() { return; } - // Validate command args - if (commandArgs.length === 0) { - console.error( - 'usage: studio --example "{{req # required arg}}" "[args... # array of args]"', - ); - process.exit(1); - } - - // Create template - const template = fromArgs(commandArgs); - const inputSchema = generateInputSchema(template); - const zodSchema = schemaToZod(inputSchema); - // Create MCP server const server = new McpServer({ name: "studio", version: VERSION, }); - // Generate tool name from base command - const toolName = template.baseCommand.replace(/-/g, "_"); - const toolDescription = `Run the shell command \`${template.getCommandFormat()}\``; - - // Register tool - server.registerTool( - toolName, - { - title: template.baseCommand, - description: toolDescription, - inputSchema: zodSchema, - }, - async (params: any) => { - try { - // Build command args - const fullCommand = buildCommandArgs(template, params); - - if (debug) { - console.error(`[Studio MCP] Executing: ${fullCommand.join(" ")}`); - } + // Determine if we're in config mode + const useConfigMode = configPath || commandsFilter.length > 0 || commandArgs.length === 0; + + if (useConfigMode) { + // Config mode: load commands from config file + const actualConfigPath = configPath || getDefaultConfigPath(); - // Execute command - const output = await execute(fullCommand[0], fullCommand.slice(1)); - - return { - content: [ - { - type: "text" as const, - text: output, - }, - ], - }; - } catch (err: any) { - const errorMsg = err.message || String(err); - return { - content: [ - { - type: "text" as const, - text: errorMsg, - }, - ], - isError: true, - }; + if (debug) { + console.error(`[Studio MCP] Loading config from: ${actualConfigPath}`); + } + + let config: StudioConfig; + try { + config = loadConfig(actualConfigPath); + } catch (err: any) { + if (!configPath && commandsFilter.length === 0 && commandArgs.length === 0) { + // No config file found and no explicit config requested + console.error( + 'usage: studio --example "{{req # required arg}}" "[args... # array of args]"', + ); + console.error(`\nOr create a config file at: ${actualConfigPath}`); + process.exit(1); } - }, - ); + throw err; + } + + // Filter commands if specified + if (commandsFilter.length > 0) { + config = filterCommands(config, commandsFilter); + } + + // Register all commands from config + for (const [name, cmdConfig] of Object.entries(config)) { + registerConfigCommand(server, name, cmdConfig, debug); + } + + if (debug) { + console.error( + `[Studio MCP] Registered ${Object.keys(config).length} command(s) from config`, + ); + } + } else { + // Template mode: single command from args + const template = fromArgs(commandArgs); + const inputSchema = generateInputSchema(template); + const zodSchema = schemaToZod(inputSchema); + + // Generate tool name from base command + const toolName = template.baseCommand.replace(/-/g, "_"); + const toolDescription = `Run the shell command \`${template.getCommandFormat()}\``; + + // Register tool + server.registerTool( + toolName, + { + title: template.baseCommand, + description: toolDescription, + inputSchema: zodSchema, + }, + async (params: any) => { + try { + // Build command args + const fullCommand = buildCommandArgs(template, params); + + if (debug) { + console.error(`[Studio MCP] Executing: ${fullCommand.join(" ")}`); + } + + // Execute command + const output = await execute(fullCommand[0], fullCommand.slice(1)); + + return { + content: [ + { + type: "text" as const, + text: output, + }, + ], + }; + } catch (err: any) { + const errorMsg = err.message || String(err); + return { + content: [ + { + type: "text" as const, + text: errorMsg, + }, + ], + isError: true, + }; + } + }, + ); + } // Create transport const transport = new StdioServerTransport(); diff --git a/src/studio/config.test.ts b/src/studio/config.test.ts new file mode 100644 index 0000000..2de1613 --- /dev/null +++ b/src/studio/config.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from "vitest"; +import { loadConfig, filterCommands, getDefaultConfigPath } from "./config.js"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +describe("config", () => { + describe("getDefaultConfigPath", () => { + test("returns XDG_CONFIG_HOME path when set", () => { + const originalXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = "/custom/config"; + expect(getDefaultConfigPath()).toBe("/custom/config/studio/config.jsonc"); + if (originalXdg) { + process.env.XDG_CONFIG_HOME = originalXdg; + } else { + delete process.env.XDG_CONFIG_HOME; + } + }); + + test("returns default path when XDG_CONFIG_HOME not set", () => { + const originalXdg = process.env.XDG_CONFIG_HOME; + delete process.env.XDG_CONFIG_HOME; + expect(getDefaultConfigPath()).toBe( + path.join(os.homedir(), ".config", "studio", "config.jsonc"), + ); + if (originalXdg) { + process.env.XDG_CONFIG_HOME = originalXdg; + } + }); + }); + + describe("loadConfig", () => { + test("loads valid JSON config", () => { + const tempFile = `/tmp/test-config-${Date.now()}.json`; + fs.writeFileSync( + tempFile, + JSON.stringify({ + echo: { + description: "Echo command", + command: ["echo", "hello"], + }, + }), + ); + + const config = loadConfig(tempFile); + expect(config).toEqual({ + echo: { + description: "Echo command", + command: ["echo", "hello"], + }, + }); + + fs.unlinkSync(tempFile); + }); + + test("loads valid JSONC with comments", () => { + const tempFile = `/tmp/test-config-${Date.now()}.jsonc`; + fs.writeFileSync( + tempFile, + `{ + // This is a comment + "echo": { + "description": "Echo command", // inline comment + "command": ["echo", "hello"] + } + /* Multi-line + comment */ + }`, + ); + + const config = loadConfig(tempFile); + expect(config).toEqual({ + echo: { + description: "Echo command", + command: ["echo", "hello"], + }, + }); + + fs.unlinkSync(tempFile); + }); + + test("throws error for non-existent file", () => { + expect(() => loadConfig("/nonexistent/file.jsonc")).toThrow( + "Config file not found", + ); + }); + + test("throws error for invalid JSON", () => { + const tempFile = `/tmp/test-config-${Date.now()}.jsonc`; + fs.writeFileSync(tempFile, "{ invalid json }"); + + expect(() => loadConfig(tempFile)).toThrow("Failed to parse config file"); + + fs.unlinkSync(tempFile); + }); + + test("throws error when config is not an object", () => { + const tempFile = `/tmp/test-config-${Date.now()}.jsonc`; + fs.writeFileSync(tempFile, "[]"); + + expect(() => loadConfig(tempFile)).toThrow("Config must be an object"); + + fs.unlinkSync(tempFile); + }); + + test("throws error when command missing command field", () => { + const tempFile = `/tmp/test-config-${Date.now()}.jsonc`; + fs.writeFileSync( + tempFile, + JSON.stringify({ + echo: { + description: "Echo command", + }, + }), + ); + + expect(() => loadConfig(tempFile)).toThrow( + 'Command "echo" is missing required "command" field', + ); + + fs.unlinkSync(tempFile); + }); + + test("throws error when command field is not an array", () => { + const tempFile = `/tmp/test-config-${Date.now()}.jsonc`; + fs.writeFileSync( + tempFile, + JSON.stringify({ + echo: { + command: "echo hello", + }, + }), + ); + + expect(() => loadConfig(tempFile)).toThrow( + 'Command "echo": "command" field must be an array', + ); + + fs.unlinkSync(tempFile); + }); + + test("throws error when command array is empty", () => { + const tempFile = `/tmp/test-config-${Date.now()}.jsonc`; + fs.writeFileSync( + tempFile, + JSON.stringify({ + echo: { + command: [], + }, + }), + ); + + expect(() => loadConfig(tempFile)).toThrow( + 'Command "echo": "command" array cannot be empty', + ); + + fs.unlinkSync(tempFile); + }); + + test("throws error when command array contains non-strings", () => { + const tempFile = `/tmp/test-config-${Date.now()}.jsonc`; + fs.writeFileSync( + tempFile, + JSON.stringify({ + echo: { + command: ["echo", 123], + }, + }), + ); + + expect(() => loadConfig(tempFile)).toThrow( + 'Command "echo": all command elements must be strings', + ); + + fs.unlinkSync(tempFile); + }); + + test("allows optional description", () => { + const tempFile = `/tmp/test-config-${Date.now()}.jsonc`; + fs.writeFileSync( + tempFile, + JSON.stringify({ + echo: { + command: ["echo", "hello"], + }, + }), + ); + + const config = loadConfig(tempFile); + expect(config).toEqual({ + echo: { + command: ["echo", "hello"], + }, + }); + + fs.unlinkSync(tempFile); + }); + + test("throws error when description is not a string", () => { + const tempFile = `/tmp/test-config-${Date.now()}.jsonc`; + fs.writeFileSync( + tempFile, + JSON.stringify({ + echo: { + description: 123, + command: ["echo", "hello"], + }, + }), + ); + + expect(() => loadConfig(tempFile)).toThrow( + 'Command "echo": "description" must be a string', + ); + + fs.unlinkSync(tempFile); + }); + }); + + describe("filterCommands", () => { + test("filters to specified commands", () => { + const config = { + echo: { command: ["echo"] }, + date: { command: ["date"] }, + pwd: { command: ["pwd"] }, + }; + + const filtered = filterCommands(config, ["echo", "pwd"]); + expect(filtered).toEqual({ + echo: { command: ["echo"] }, + pwd: { command: ["pwd"] }, + }); + }); + + test("throws error when command not found", () => { + const config = { + echo: { command: ["echo"] }, + }; + + expect(() => filterCommands(config, ["nonexistent"])).toThrow( + 'Command "nonexistent" not found in config', + ); + }); + + test("returns empty object when filtering to empty list", () => { + const config = { + echo: { command: ["echo"] }, + }; + + const filtered = filterCommands(config, []); + expect(filtered).toEqual({}); + }); + }); +}); diff --git a/src/studio/config.ts b/src/studio/config.ts new file mode 100644 index 0000000..e113932 --- /dev/null +++ b/src/studio/config.ts @@ -0,0 +1,114 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +/** + * Config file schema + */ +export interface CommandConfig { + description?: string; + command: string[]; +} + +export interface StudioConfig { + [commandName: string]: CommandConfig; +} + +/** + * Get XDG config directory + */ +function getXdgConfigHome(): string { + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); +} + +/** + * Get default config file path + */ +export function getDefaultConfigPath(): string { + return path.join(getXdgConfigHome(), "studio", "config.jsonc"); +} + +/** + * Strip comments from JSONC content + */ +function stripJsonComments(content: string): string { + // Remove single-line comments + content = content.replace(/\/\/.*$/gm, ""); + + // Remove multi-line comments + content = content.replace(/\/\*[\s\S]*?\*\//g, ""); + + return content; +} + +/** + * Load and parse a config file + */ +export function loadConfig(configPath: string): StudioConfig { + if (!fs.existsSync(configPath)) { + throw new Error(`Config file not found: ${configPath}`); + } + + const content = fs.readFileSync(configPath, "utf-8"); + const jsonContent = stripJsonComments(content); + + let config: any; + try { + config = JSON.parse(jsonContent); + } catch (err: any) { + throw new Error(`Failed to parse config file: ${err.message}`); + } + + // Validate config structure + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw new Error("Config must be an object"); + } + + for (const [name, cmd] of Object.entries(config)) { + if (typeof cmd !== "object" || cmd === null) { + throw new Error(`Command "${name}" must be an object`); + } + + const cmdConfig = cmd as any; + + if (!cmdConfig.command) { + throw new Error(`Command "${name}" is missing required "command" field`); + } + + if (!Array.isArray(cmdConfig.command)) { + throw new Error(`Command "${name}": "command" field must be an array`); + } + + if (cmdConfig.command.length === 0) { + throw new Error(`Command "${name}": "command" array cannot be empty`); + } + + for (const arg of cmdConfig.command) { + if (typeof arg !== "string") { + throw new Error(`Command "${name}": all command elements must be strings`); + } + } + + if (cmdConfig.description !== undefined && typeof cmdConfig.description !== "string") { + throw new Error(`Command "${name}": "description" must be a string`); + } + } + + return config as StudioConfig; +} + +/** + * Filter config to only include specified commands + */ +export function filterCommands(config: StudioConfig, commandNames: string[]): StudioConfig { + const filtered: StudioConfig = {}; + + for (const name of commandNames) { + if (!(name in config)) { + throw new Error(`Command "${name}" not found in config`); + } + filtered[name] = config[name]; + } + + return filtered; +}