diff --git a/AGENTS.md b/AGENTS.md index 0cc0680..12d220d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Overview -This document provides instructions for AI agents on how to work with and extend the Make CLI repository. The CLI is a standalone command-line tool that interacts with the Make automation platform. It depends on `@makehq/sdk` for all API access, types, and MCP tool definitions. +This document provides instructions for AI agents on how to work with and extend the Make CLI repository. The CLI is a standalone command-line tool that interacts with the Make automation platform. It depends on `@makehq/sdk` for all API access, types, and tool definitions. ## Repository Structure @@ -10,7 +10,7 @@ This document provides instructions for AI agents on how to work with and extend make-cli/ ├── src/ │ ├── index.ts # Executable entry point: sets up Commander, registers all commands -│ ├── commands.ts # Builds CLI commands from @makehq/sdk MCP tool definitions +│ ├── commands.ts # Builds CLI commands from @makehq/sdk tool definitions │ ├── auth.ts # Resolves API key and zone from flags, env vars, or config file │ ├── config.ts # Reads/writes local credentials file (~/.config/make-cli/config.json) │ ├── login.ts # Hand-crafted login, logout, whoami commands @@ -31,29 +31,53 @@ make-cli/ All API functionality comes from the `@makehq/sdk` package. The CLI imports: -| Import | Source | Purpose | -| -------------- | ----------------- | ------------------------------------------------ | -| `Make` | `@makehq/sdk` | API client — instantiated per command invocation | -| `MakeError` | `@makehq/sdk` | Typed API error with `statusCode` and `message` | -| `JSONValue` | `@makehq/sdk` | Generic JSON value type | -| `MakeMCPTools` | `@makehq/sdk/mcp` | Array of all MCP tool definitions | -| `MakeMCPTool` | `@makehq/sdk/mcp` | Type describing a single MCP tool | -| `JSONSchema` | `@makehq/sdk/mcp` | JSON Schema type for tool input parameters | +| Import | Source | Purpose | +| ------------- | ------------------- | --------------------------------------------------- | +| `Make` | `@makehq/sdk` | API client — instantiated per command invocation | +| `MakeError` | `@makehq/sdk` | Typed API error with `statusCode` and `message` | +| `JSONValue` | `@makehq/sdk` | Generic JSON value type | +| `MakeTools` | `@makehq/sdk/tools` | Array of all Make SDK tool definitions | +| `MakeTool` | `@makehq/sdk/tools` | Type describing a single tool | +| `JSONSchema` | `@makehq/sdk/tools` | JSON Schema type for tool input parameters | ## How the CLI Works -The CLI uses an **auto-discovery pattern**: it reads the `MakeMCPTools` array from `@makehq/sdk/mcp` and dynamically registers each tool as a CLI subcommand. No command wiring is done by hand. +The CLI uses an **auto-discovery pattern**: it reads the `MakeTools` array from `@makehq/sdk/tools` and dynamically registers each tool as a CLI subcommand. No command wiring is done by hand. ### Command registration flow 1. `src/index.ts` creates a Commander program with global flags (`--api-key`, `--zone`, `--output`) -2. It calls `buildCommands(program, MakeMCPTools)` from `src/commands.ts` +2. It calls `buildCommands(program, MakeTools)` from `src/commands.ts` 3. `buildCommands` groups tools by `tool.category` and creates nested subcommands: - Category → top-level command (e.g. `scenarios`, `data-stores`, `sdk-apps`) - Tool action → subcommand (e.g. `list`, `get`, `create`) 4. Each subcommand's options are derived from `tool.inputSchema.properties` 5. On execution, the tool's `execute(make, args)` function is called +### Positional argument for resource-level actions + +Tools that operate on a single resource (typically `get` / `update` / `delete` / action-style tools) declare the owning input property via `tool.resourceId` (e.g. `dataStructureId` for `data-structures_get`, `executionId` for `executions_get`, `key` for `data-store-records_update`). For these tools the CLI exposes that value as a positional argument. The original descriptive long-form flag is kept as an alternative for scripted / explicit use, so both of these work and map to the same SDK input: + +``` +make-cli data-structures get 178 +make-cli data-structures get --data-structure-id=178 +``` + +When the tool also declares a parent scope (`tool.scopeId`), that stays a named flag — only the resource's own id becomes positional: + +``` +make-cli executions get abc --scenario-id=925 +``` + +Behavior details: + +- The positional argument is registered as optional at the Commander level (`[resource-id]`) so either invocation style parses cleanly. Presence is enforced in the action handler based on the JSON Schema's `required` list. +- Supplying the value both positionally and via the flag is rejected with an explicit error. +- The positional is not registered when `tool.resourceId` is unset (collection-level `list` / `create`) or when it points at a property that isn't part of the schema. +- Generated help text shows the positional in the Usage line and in an `Arguments:` section; built-in examples are rendered using the positional form. + +See `deriveSelfIdentifier` and `registerToolAsCommand` in `src/commands.ts`. + ### Tool name → CLI command mapping Tool names follow `{category}_{action}` where category dots become hyphens: @@ -94,7 +118,7 @@ The file is written with mode `0o600` (owner-read/write only on Unix) and uses a | `make-cli logout` | Removes the local credentials file | | `make-cli whoami` | Calls `make.users.me()` and prints `name`, `email`, and `zone` | -These are intentionally separate from `buildCommands` — they are not auto-discovered from MCP tools. +These are intentionally separate from `buildCommands` — they are not auto-discovered from SDK tools. ### Output formatting @@ -106,9 +130,9 @@ Controlled by the global `--output` flag (default: `json`): ## Adding New Commands -New CLI commands come automatically from new MCP tools added in `@makehq/sdk`. To add a command: +New CLI commands come automatically from new SDK tools added in `@makehq/sdk`. To add a command: -1. Add or update a `.mcp.ts` file in the `@makehq/sdk` repository following its conventions +1. Add or update a `.tool.ts` file in the `@makehq/sdk` repository following its conventions (setting `resourceId` on resource-level tools so the CLI can register the positional argument) 2. Bump and publish a new version of `@makehq/sdk` 3. Update `@makehq/sdk` version in this repo's `package.json` and run `npm install` 4. No code changes needed in this repo — the new tool is auto-discovered @@ -151,7 +175,7 @@ The build produces a single file: `dist/index.js` — an ESM executable with `#! ## TypeScript Guidelines - Use `type` imports for type-only imports -- All imports from `@makehq/sdk` and `@makehq/sdk/mcp` use the package name (never relative paths into node_modules) +- All imports from `@makehq/sdk` and `@makehq/sdk/tools` use the package name (never relative paths into node_modules) - Use `.js` extensions in relative imports (e.g. `import { run } from './index.js'`) ## Quality Checklist diff --git a/README.md b/README.md index eb55836..796e396 100644 --- a/README.md +++ b/README.md @@ -94,29 +94,35 @@ Flags take priority over environment variables, which take priority over saved c ## Usage ``` -make-cli [options] [options] +make-cli [options] [resource-id] [options] ``` ### Global Options -| Option | Description | -| ----------------- | ------------------------------------------------------------------ | -| `-V, --version` | Output the version number | -| `--api-key ` | Make API key (or set `MAKE_API_KEY`) | -| `--zone ` | Make zone (e.g. `eu2.make.com`) (or set `MAKE_ZONE`) | -| `--output` | Output format: `json` (default), `compact`, `table` | -| `-h, --help` | Display help for the command | +| Option | Description | +| ----------------- | ---------------------------------------------------- | +| `-V, --version` | Output the version number | +| `--api-key ` | Make API key (or set `MAKE_API_KEY`) | +| `--zone ` | Make zone (e.g. `eu2.make.com`) (or set `MAKE_ZONE`) | +| `--output` | Output format: `json` (default), `compact`, `table` | +| `-h, --help` | Display help for the command | ### Examples ```bash +# Listing — scope is always a named flag make-cli scenarios list --team-id=123 -make-cli scenarios get --scenario-id=456 make-cli connections list --team-id=123 make-cli data-stores list --team-id=123 make-cli data-store-records list --data-store-id=1 make-cli teams list --organization-id=1 -make-cli users me + +# Resource-level actions — resource id as positional argument +make-cli scenarios get 456 +make-cli data-structures get 178 +make-cli data-store-records update ecc4819b2260 \ + --data-store-id=137 \ + --data='{"status":"inactive"}' # Creating a scenario make-cli scenarios create \ @@ -128,6 +134,23 @@ make-cli scenarios create \ make-cli scenarios list --team-id=123 --output=table ``` +### Resource IDs + +Resource-level actions (`get`, `update`, `delete`, and similar) accept the resource's own ID as a **positional argument**. The long-form flag is still available for scripting or when you prefer being explicit — both forms are equivalent: + +```bash +make-cli scenarios get 456 +make-cli scenarios get --scenario-id=456 +``` + +Parent scopes (e.g. `--team-id`, `--organization-id`, or `--scenario-id` on nested resources) stay as named flags, so only the resource's own id is positional: + +```bash +make-cli executions get a07e16f2ad134bf49cf83a00aa95c0a5 --scenario-id=925 +``` + +Collection-level actions (`list`, `create`, ...) have no positional ID — every input is a named flag. + ### Commands Commands are organized by category: diff --git a/package-lock.json b/package-lock.json index 085cab1..707b6a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@makehq/cli", - "version": "1.3.1", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@makehq/cli", - "version": "1.3.1", + "version": "1.4.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.3.2", - "@makehq/sdk": "^1.2.1", + "@makehq/sdk": "^1.3.0", "commander": "^14.0.3", "open": "^11.0.0" }, @@ -2043,9 +2043,9 @@ } }, "node_modules/@makehq/sdk": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@makehq/sdk/-/sdk-1.2.1.tgz", - "integrity": "sha512-VYuNmZqMWl0yxLdKa3w+pXhGGRL5p3io78BQxrw+kDNpns3AiXfRojjbqkEl2xaWZXdb0ShNLIdwPU8PElWC5g==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@makehq/sdk/-/sdk-1.3.0.tgz", + "integrity": "sha512-XmzRpzNKcJTgRl2oBZZSxB0r4oGRid4hFGmiNlw01pqO4BUoaC78oIAgiYvt2JTSqyPjos3Q2M1hRhQc4lL1xQ==", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index eae0544..f5db76e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makehq/cli", - "version": "1.3.1", + "version": "1.4.0", "description": "A command-line tool for Make automation platform", "license": "MIT", "author": "Make", @@ -28,7 +28,7 @@ ], "dependencies": { "@inquirer/prompts": "^8.3.2", - "@makehq/sdk": "^1.2.1", + "@makehq/sdk": "^1.3.0", "commander": "^14.0.3", "open": "^11.0.0" }, diff --git a/scripts/build-docs.ts b/scripts/build-docs.ts index 8e66cd6..0d08371 100644 --- a/scripts/build-docs.ts +++ b/scripts/build-docs.ts @@ -1,11 +1,11 @@ import { writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { MakeMCPTools } from '@makehq/sdk/mcp'; -import type { MakeMCPTool, JSONSchema } from '@makehq/sdk/mcp'; +import { MakeTools } from '@makehq/sdk/tools'; +import type { MakeTool, JSONSchema } from '@makehq/sdk/tools'; import { CATEGORY_TITLES, CATEGORY_GROUPS } from '../src/categories.js'; import { camelToKebab, formatExampleCommand } from '../src/examples.js'; -import { deriveActionName } from '../src/commands.js'; +import { deriveActionName, deriveSelfIdentifier } from '../src/commands.js'; const DOCS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'docs'); @@ -18,7 +18,7 @@ function schemaTypeLabel(schema: JSONSchema): string { return type ?? 'string'; } -function buildToolSection(tool: MakeMCPTool, categorySlug: string): string { +function buildToolSection(tool: MakeTool, categorySlug: string): string { const action = deriveActionName(tool.name, tool.category); const lines: string[] = []; @@ -30,27 +30,54 @@ function buildToolSection(tool: MakeMCPTool, categorySlug: string): string { const properties = tool.inputSchema.properties ?? {}; const required = new Set(tool.inputSchema.required ?? []); const propEntries = Object.entries(properties); + const selfIdProperty = deriveSelfIdentifier(tool); + const selfIdSchema = selfIdProperty ? properties[selfIdProperty] : undefined; + const selfIdFlag = selfIdProperty ? `--${camelToKebab(selfIdProperty)}` : undefined; + + if (selfIdProperty && selfIdSchema && selfIdFlag) { + const argName = camelToKebab(selfIdProperty); + const isRequired = required.has(selfIdProperty); + // Keep positional argument notation aligned with the CLI `--help` Usage + // lines, which render this self identifier as `[arg]` regardless of + // logical requiredness — the positional is always registered as + // optional at the Commander level so the `--` flag alternative + // stays valid. Requiredness is conveyed separately via the Required + // column and the description. + const argToken = `[${argName}]`; + const rawDesc = selfIdSchema.description?.replace(/\|/g, '\\|').replace(/\n/g, ' ').trim() ?? ''; + // SDK descriptions generally omit trailing punctuation; normalize to a + // single '.' so the cross-reference clause reads as a second sentence. + const baseDesc = rawDesc ? rawDesc.replace(/[.!?]+$/, '') + '.' : ''; + const desc = baseDesc + ? `${baseDesc} Can also be passed as \`${selfIdFlag}=\`.` + : `Can also be passed as \`${selfIdFlag}=\`.`; + + lines.push('**Arguments**'); + lines.push(''); + lines.push('| Argument | Description | Required |'); + lines.push('|----------|-------------|----------|'); + lines.push(`| \`${argToken}\` | ${desc} | ${isRequired ? 'Yes' : 'No'} |`); + lines.push(''); + } + + const flagEntries = propEntries.filter(([propName]) => propName !== selfIdProperty); - if (propEntries.length > 0) { + if (flagEntries.length > 0) { lines.push('**Options**'); lines.push(''); lines.push('| Option | Description | Required |'); lines.push('|--------|-------------|----------|'); - for (const [propName, schema] of propEntries) { + for (const [propName, schema] of flagEntries) { const flagName = camelToKebab(propName); const type = schemaTypeLabel(schema); const isBooleanFlag = type === 'boolean'; - const flag = isBooleanFlag - ? schema.default === true - ? `--no-${flagName}` - : `--${flagName}` - : `--${flagName}`; + const longForm = isBooleanFlag && schema.default === true ? `--no-${flagName}` : `--${flagName}`; const isRequired = required.has(propName) && !isBooleanFlag; const propDesc = schema.description?.replace(/\|/g, '\\|').replace(/\n/g, ' ') ?? ''; - lines.push(`| \`${flag}\` | ${propDesc} | ${isRequired ? 'Yes' : 'No'} |`); + lines.push(`| \`${longForm}\` | ${propDesc} | ${isRequired ? 'Yes' : 'No'} |`); } lines.push(''); @@ -63,7 +90,7 @@ function buildToolSection(tool: MakeMCPTool, categorySlug: string): string { const cmd = `make-cli ${categorySlug} ${action}`; const example = tool.examples?.[0]; if (example && Object.keys(example).length > 0) { - lines.push(formatExampleCommand(cmd, example)); + lines.push(formatExampleCommand(cmd, example, selfIdProperty)); } else { lines.push(cmd); } @@ -73,7 +100,7 @@ function buildToolSection(tool: MakeMCPTool, categorySlug: string): string { return lines.join('\n'); } -function buildCategoryDoc(categorySlug: string, tools: MakeMCPTool[]): string { +function buildCategoryDoc(categorySlug: string, tools: MakeTool[]): string { const originalCategory = tools[0]!.category; const title = CATEGORY_TITLES[originalCategory] ?? categorySlug; const lines: string[] = []; @@ -98,7 +125,7 @@ function buildCategoryDoc(categorySlug: string, tools: MakeMCPTool[]): string { return lines.join('\n'); } -function buildIndex(categoryMap: Map): string { +function buildIndex(categoryMap: Map): string { const lines: string[] = []; lines.push('# Make CLI Documentation'); @@ -160,9 +187,9 @@ function buildIndex(categoryMap: Map): string { // --- Main --- -const categoryMap = new Map(); +const categoryMap = new Map(); -for (const tool of MakeMCPTools) { +for (const tool of MakeTools) { const slug = tool.category.replace(/\./g, '-'); const group = categoryMap.get(slug) ?? []; group.push(tool); @@ -181,6 +208,6 @@ for (const [slug, tools] of categoryMap) { const index = buildIndex(categoryMap); writeFileSync(join(DOCS_DIR, 'README.md'), index); -const totalTools = MakeMCPTools.length; +const totalTools = MakeTools.length; const totalCategories = categoryMap.size; console.log(`Generated docs for ${totalTools} commands across ${totalCategories} categories in docs/`); diff --git a/src/commands.ts b/src/commands.ts index c5a0bb8..a1c6ff1 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,6 @@ -import type { Command } from 'commander'; +import type { Command, OptionValues } from 'commander'; import { Command as CommandClass } from 'commander'; -import type { MakeMCPTool, JSONSchema } from '@makehq/sdk/mcp'; +import type { MakeTool, JSONSchema } from '@makehq/sdk/tools'; import type { JSONValue } from '@makehq/sdk'; import { Make } from '@makehq/sdk'; import { MakeError } from '@makehq/sdk'; @@ -10,7 +10,7 @@ import { CATEGORY_TITLES, CATEGORY_GROUPS } from './categories.js'; import { camelToKebab, formatExampleCommand } from './examples.js'; /** - * Derives the CLI action name from an MCP tool name and its category. + * Derives the CLI action name from a Make SDK tool name and its category. * * Tool names follow the pattern `{category}_{action}` where dots in the category * are replaced with hyphens (e.g., 'sdk.apps' → 'sdk-apps'). @@ -27,10 +27,29 @@ export function deriveActionName(toolName: string, category: string): string { } /** - * Converts a kebab-case string to camelCase. + * Returns the input-schema property name that represents the tool's own + * resource ID and should be exposed as a positional argument on the CLI. + * Returns undefined when no positional form should apply. + * + * The SDK declares this property via `tool.resourceId`. It is distinct from + * `tool.scopeId`, which names the parent/scope ID used for routing and + * access control. A single tool may have both — e.g. `executions_get` has + * `scopeId = 'scenarioId'` and `resourceId = 'executionId'`, so the command + * is invoked as `executions get --scenario-id=`. + * + * We skip positional registration when: + * - `tool.resourceId` is unset (collection-level actions like list/create). + * - `tool.resourceId` points at a property that doesn't exist in the schema + * (guards against SDK definition drift). */ -function kebabToCamel(str: string): string { - return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +export function deriveSelfIdentifier(tool: MakeTool): string | undefined { + const resourceId = tool.resourceId; + if (!resourceId) return undefined; + const properties = tool.inputSchema.properties ?? {}; + // Use an own-property check so a `resourceId` that happens to match an + // Object.prototype method name (e.g. `toString`) is not treated as present + // when the schema doesn't actually declare it. + return Object.hasOwn(properties, resourceId) ? resourceId : undefined; } /** @@ -84,22 +103,34 @@ function getOrCreateSubcommand(parent: Command, name: string, description: strin } /** - * Registers an MCP tool as a CLI command on a parent Commander command. + * Registers a Make SDK tool as a CLI command on a parent Commander command. + * + * When the tool declares a `resourceId` that points at a schema property, the + * command exposes that value both as a long-form flag (e.g. + * `--data-structure-id=178`) and as an optional positional argument (e.g. + * `data-structures get 178`). The positional is marked optional in Commander + * so either invocation style parses cleanly; we then enforce presence + * (when the schema requires it) and reject ambiguous duplication ourselves. */ -function registerToolAsCommand(parent: Command, tool: MakeMCPTool, category: string): void { +function registerToolAsCommand(parent: Command, tool: MakeTool, category: string): void { const actionName = deriveActionName(tool.name, category); const cmd = parent.command(actionName).description(tool.description); const properties = tool.inputSchema.properties ?? {}; const required = new Set(tool.inputSchema.required ?? []); + const selfIdProperty = deriveSelfIdentifier(tool); + const selfIdSchema = selfIdProperty ? properties[selfIdProperty] : undefined; + const selfIdRequired = selfIdProperty ? required.has(selfIdProperty) : false; + const selfIdFlag = selfIdProperty ? `--${camelToKebab(selfIdProperty)}` : undefined; for (const [propName, schema] of Object.entries(properties)) { const flagName = camelToKebab(propName); const type = Array.isArray(schema.type) ? schema.type[0] : schema.type; const isRequired = required.has(propName); - const isBooleanFlag = type === 'boolean'; + const isSelfId = propName === selfIdProperty; + const flag = isBooleanFlag ? schema.default === true ? `--no-${flagName}` @@ -108,7 +139,10 @@ function registerToolAsCommand(parent: Command, tool: MakeMCPTool, category: str const option = cmd.createOption(flag, schema.description ?? ''); - if (isRequired && !isBooleanFlag) { + // Self-id properties accept either the flag or the positional argument, + // so we deliberately skip Commander's built-in required-flag check and + // do our own validation in the action (see below). + if (isRequired && !isBooleanFlag && !isSelfId) { option.makeOptionMandatory(true); } if (schema.enum) { @@ -122,10 +156,15 @@ function registerToolAsCommand(parent: Command, tool: MakeMCPTool, category: str cmd.addOption(option); } + if (selfIdProperty) { + const argName = camelToKebab(selfIdProperty); + cmd.argument(`[${argName}]`, selfIdSchema?.description ?? ''); + } + const example = tool.examples?.[0]; if (example && Object.keys(example).length > 0) { const slug = category.replace(/\./g, '-'); - const exampleCmd = formatExampleCommand(`make-cli ${slug} ${actionName}`, example); + const exampleCmd = formatExampleCommand(`make-cli ${slug} ${actionName}`, example, selfIdProperty); const indented = exampleCmd .split('\n') .map(l => ' ' + l) @@ -133,7 +172,23 @@ function registerToolAsCommand(parent: Command, tool: MakeMCPTool, category: str cmd.addHelpText('after', `\nExample:\n\n${indented}\n`); } - cmd.action(async (localOptions: Record) => { + const handler = async (positional: string | undefined, localOptions: OptionValues): Promise => { + if (selfIdProperty && selfIdFlag) { + const fromFlag = localOptions[selfIdProperty]; + if (positional !== undefined && fromFlag !== undefined) { + process.stderr.write( + `Error: ${selfIdFlag} was supplied both positionally and as a flag; pass it only one way.\n`, + ); + process.exit(1); + } + if (positional === undefined && fromFlag === undefined && selfIdRequired) { + process.stderr.write( + `Error: missing required argument — pass the resource ID positionally or via ${selfIdFlag}.\n`, + ); + process.exit(1); + } + } + const globalOptions = cmd.optsWithGlobals(); const { token, zone } = await resolveAuth({ apiKey: globalOptions.apiKey, @@ -145,15 +200,18 @@ function registerToolAsCommand(parent: Command, tool: MakeMCPTool, category: str for (const [key, value] of Object.entries(localOptions)) { if (value === undefined) continue; - const camelKey = kebabToCamel(key); - const schema = properties[camelKey]; + const schema = properties[key]; if (schema) { - args[camelKey] = coerceValue(String(value), schema); + args[key] = coerceValue(String(value), schema); } else { - args[camelKey] = value; + args[key] = value; } } + if (selfIdProperty && positional !== undefined && selfIdSchema) { + args[selfIdProperty] = coerceValue(positional, selfIdSchema); + } + try { const result = await tool.execute(make, args); const format = (globalOptions.output as OutputFormat) ?? 'json'; @@ -170,15 +228,21 @@ function registerToolAsCommand(parent: Command, tool: MakeMCPTool, category: str process.exit(1); } } - }); + }; + + if (selfIdProperty) { + cmd.action((positional: string | undefined, localOptions: OptionValues) => handler(positional, localOptions)); + } else { + cmd.action((localOptions: OptionValues) => handler(undefined, localOptions)); + } } /** - * Builds all CLI commands from MCP tool definitions. + * Builds all CLI commands from Make SDK tool definitions. * Groups tools by category and creates nested subcommands. */ -export function buildCommands(program: Command, tools: MakeMCPTool[]): void { - const categories = new Map(); +export function buildCommands(program: Command, tools: MakeTool[]): void { + const categories = new Map(); for (const tool of tools) { const group = categories.get(tool.category) ?? []; diff --git a/src/examples.ts b/src/examples.ts index 8986c75..2196198 100644 --- a/src/examples.ts +++ b/src/examples.ts @@ -73,13 +73,37 @@ export function formatExampleValue(value: JSONValue): { flat: string; expanded: * Builds a formatted shell example command from a base command and an example payload. * Uses single-line format when the result fits within 80 characters, * otherwise switches to multi-line with backslash continuations. + * + * When `positionalKey` is provided and present in the example, its value is + * rendered as a positional argument (placed right after the command) instead + * of a `--flag=value` pair. The key is removed from the flag section so the + * same value isn't emitted twice. */ -export function formatExampleCommand(command: string, example: Record): string { +export function formatExampleCommand( + command: string, + example: Record, + positionalKey?: string, +): string { const entries = Object.entries(example).filter(([, v]) => v !== false); if (entries.length === 0) return command; + // Resolve the positional against the already-filtered entries so that a + // `false` value on the positional key is treated the same as any other + // flag: skipped, not rendered as `command false`. + const positionalEntry = positionalKey ? entries.find(([name]) => name === positionalKey) : undefined; + const flagEntries = positionalEntry ? entries.filter(([name]) => name !== positionalKey) : entries; + + let head = command; + if (positionalEntry) { + const value = positionalEntry[1]; + const positional = typeof value === 'boolean' ? String(value) : formatExampleValue(value).flat; + head = `${command} ${positional}`; + } + + if (flagEntries.length === 0) return head; + const args: { flat: string; expanded: string }[] = []; - for (const [name, value] of entries) { + for (const [name, value] of flagEntries) { const flag = `--${camelToKebab(name)}`; if (typeof value === 'boolean') { args.push({ flat: flag, expanded: flag }); @@ -92,14 +116,14 @@ export function formatExampleCommand(command: string, example: Record a.flat).join(' ')}`; + const singleLine = `${head} ${args.map(a => a.flat).join(' ')}`; const hasMultiLine = args.some(a => a.expanded.includes('\n')); if (!hasMultiLine && singleLine.length <= 80) { return singleLine; } - const blocks = [command, ...args.map(a => ` ${a.expanded}`)]; + const blocks = [head, ...args.map(a => ` ${a.expanded}`)]; const parts: string[] = []; for (let i = 0; i < blocks.length; i++) { if (i < blocks.length - 1) { diff --git a/src/index.ts b/src/index.ts index be9695c..56c562d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { Command, Option } from 'commander'; -import { MakeMCPTools } from '@makehq/sdk/mcp'; +import { MakeTools } from '@makehq/sdk/tools'; import { buildCommands } from './commands.js'; import { registerLoginCommands } from './login.js'; @@ -15,7 +15,7 @@ program .option('--zone ', 'Make zone, e.g. eu1.make.com (or set MAKE_ZONE)') .addOption(new Option('--output ', 'Output format').choices(['json', 'compact', 'table']).default('json')); -buildCommands(program, MakeMCPTools); +buildCommands(program, MakeTools); registerLoginCommands(program); program.parseAsync(process.argv).catch(err => { diff --git a/test/commands.spec.ts b/test/commands.spec.ts index 6d7729d..afa37a6 100644 --- a/test/commands.spec.ts +++ b/test/commands.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it, beforeEach, afterEach, jest } from '@jest/globals'; import { Command, Option } from 'commander'; -import { deriveActionName, coerceValue, buildCommands } from '../src/commands.js'; +import { deriveActionName, coerceValue, buildCommands, deriveSelfIdentifier } from '../src/commands.js'; import { camelToKebab } from '../src/examples.js'; import { resolveAuth } from '../src/auth.js'; import { formatOutput } from '../src/output.js'; -import type { MakeMCPTool } from '@makehq/sdk/mcp'; +import type { MakeTool } from '@makehq/sdk/tools'; jest.mock('../src/config.js', () => ({ readConfig: jest.fn<() => Promise>().mockResolvedValue(null), @@ -147,7 +147,7 @@ describe('CLI: resolveAuth', () => { }); describe('CLI: buildCommands', () => { - const makeTool = (overrides: Partial = {}): MakeMCPTool => ({ + const makeTool = (overrides: Partial = {}): MakeTool => ({ name: 'scenarios_list', title: 'List scenarios', description: 'List all scenarios', @@ -320,7 +320,7 @@ describe('CLI: buildCommands', () => { }); it('should execute a tool and write formatted output to stdout', async () => { - const execute = jest.fn().mockResolvedValue([{ id: 1, name: 'Test' }]); + const execute = jest.fn().mockResolvedValue([{ id: 1, name: 'Test' }]); const program = new Command(); program @@ -346,6 +346,364 @@ describe('CLI: buildCommands', () => { }); }); +describe('CLI: deriveSelfIdentifier', () => { + const makeTool = (overrides: Partial): MakeTool => ({ + name: 'x_y', + title: 'x', + description: 'x', + category: 'x', + inputSchema: { type: 'object', properties: {}, required: [] }, + execute: async () => null, + ...overrides, + }); + + const withResource = (resourceId: string, extra: Partial = {}): MakeTool => + makeTool({ + resourceId, + inputSchema: { + type: 'object', + properties: { [resourceId]: { type: 'number', description: `${resourceId}` } }, + required: [resourceId], + }, + ...extra, + }); + + it('returns the resourceId when the schema declares that property', () => { + expect(deriveSelfIdentifier(withResource('dataStructureId'))).toBe('dataStructureId'); + expect(deriveSelfIdentifier(withResource('scenarioId'))).toBe('scenarioId'); + expect(deriveSelfIdentifier(withResource('requestId'))).toBe('requestId'); + expect(deriveSelfIdentifier(withResource('key'))).toBe('key'); + }); + + it('returns the resourceId even when it is literally `id`', () => { + // Under the positional model there is no alias collision to guard against: + // a positional arg named [id] coexists fine with a `--id ` option. + expect(deriveSelfIdentifier(withResource('id'))).toBe('id'); + }); + + it('returns the resourceId when the schema also defines an unrelated `id` property', () => { + // The old alias model skipped this case to avoid a `--id` flag collision. + // Positional + `--id` option have no name collision, so we no longer skip. + expect( + deriveSelfIdentifier( + makeTool({ + resourceId: 'dataStructureId', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Unrelated id field' }, + dataStructureId: { type: 'number', description: 'Data structure id' }, + }, + required: ['dataStructureId'], + }, + }), + ), + ).toBe('dataStructureId'); + }); + + it('returns the resourceId when tool.scopeId points at a different parent-scope property', () => { + expect( + deriveSelfIdentifier( + makeTool({ + name: 'executions_get', + category: 'executions', + scopeId: 'scenarioId', + resourceId: 'executionId', + inputSchema: { + type: 'object', + properties: { + scenarioId: { type: 'number', description: 'Parent scenario' }, + executionId: { type: 'string', description: 'Execution id' }, + }, + required: ['scenarioId', 'executionId'], + }, + }), + ), + ).toBe('executionId'); + }); + + it('returns undefined when resourceId is not set (collection-level actions)', () => { + expect( + deriveSelfIdentifier( + makeTool({ + scopeId: 'teamId', + inputSchema: { + type: 'object', + properties: { teamId: { type: 'number', description: 'Team id' } }, + required: ['teamId'], + }, + }), + ), + ).toBeUndefined(); + }); + + it('returns undefined when resourceId points at a property that is missing from the schema', () => { + expect( + deriveSelfIdentifier( + makeTool({ + resourceId: 'missingId', + inputSchema: { type: 'object', properties: {}, required: [] }, + }), + ), + ).toBeUndefined(); + }); + + it('returns undefined when resourceId collides with an Object.prototype key but is not declared on the schema', () => { + // Guards against a `resourceId` like `toString` or `hasOwnProperty` + // being treated as present via the prototype chain. Only own + // properties declared on the schema should count. + for (const protoKey of ['toString', 'hasOwnProperty', 'constructor', '__proto__']) { + expect( + deriveSelfIdentifier( + makeTool({ + resourceId: protoKey, + inputSchema: { type: 'object', properties: {}, required: [] }, + }), + ), + ).toBeUndefined(); + } + }); +}); + +describe('CLI: positional argument for self identifiers', () => { + const makeTool = (overrides: Partial = {}): MakeTool => ({ + name: 'data-structures_get', + title: 'Get data structure', + description: 'Get details of a specific data structure.', + category: 'data-structures', + scopeId: 'dataStructureId', + resourceId: 'dataStructureId', + inputSchema: { + type: 'object', + properties: { dataStructureId: { type: 'number', description: 'The data structure ID to retrieve' } }, + required: ['dataStructureId'], + }, + execute: async () => null, + ...overrides, + }); + + const getCommand = (program: Command, category: string, action: string) => + program.commands.find(c => c.name() === category)?.commands.find(c => c.name() === action); + + const getOption = (program: Command, category: string, action: string, longFlag: string) => + getCommand(program, category, action)?.options.find(o => o.long === longFlag); + + const buildProgramWithTool = (tool: MakeTool): Command => { + const program = new Command(); + program + .option('--api-key ') + .option('--zone ') + .addOption(new Option('--output ').choices(['json', 'compact', 'table']).default('json')); + buildCommands(program, [tool]); + return program; + }; + + it('registers an optional positional argument when resourceId is declared', () => { + const program = new Command(); + buildCommands(program, [makeTool()]); + + const cmd = getCommand(program, 'data-structures', 'get'); + expect(cmd).toBeDefined(); + + const args = cmd!.registeredArguments; + expect(args).toHaveLength(1); + expect(args[0]?.name()).toBe('data-structure-id'); + expect(args[0]?.required).toBe(false); + expect(args[0]?.description).toBe('The data structure ID to retrieve'); + }); + + it('keeps the long-form flag but does not make it mandatory at the Commander level', () => { + const program = new Command(); + buildCommands(program, [makeTool()]); + + const opt = getOption(program, 'data-structures', 'get', '--data-structure-id'); + expect(opt).toBeDefined(); + expect(opt?.flags).toBe('--data-structure-id '); + expect(opt?.mandatory).toBe(false); + }); + + it('does not register any `--id` flag', () => { + const program = new Command(); + buildCommands(program, [makeTool()]); + + const idOpt = getOption(program, 'data-structures', 'get', '--id'); + expect(idOpt).toBeUndefined(); + + const opt = getOption(program, 'data-structures', 'get', '--data-structure-id'); + expect(opt?.short).toBeUndefined(); + }); + + it('does not register a positional on collection-level commands (no resourceId)', () => { + const program = new Command(); + buildCommands(program, [ + makeTool({ + name: 'data-structures_list', + scopeId: 'teamId', + resourceId: undefined, + inputSchema: { + type: 'object', + properties: { teamId: { type: 'number', description: 'Team ID' } }, + required: ['teamId'], + }, + }), + ]); + + const cmd = getCommand(program, 'data-structures', 'list'); + expect(cmd!.registeredArguments).toHaveLength(0); + + const teamOpt = getOption(program, 'data-structures', 'list', '--team-id'); + expect(teamOpt?.mandatory).toBe(true); + }); + + it('registers only the resource id positionally when a scope flag is also required', () => { + const program = new Command(); + buildCommands(program, [ + makeTool({ + name: 'executions_get', + category: 'executions', + scopeId: 'scenarioId', + resourceId: 'executionId', + inputSchema: { + type: 'object', + properties: { + scenarioId: { type: 'number', description: 'Parent scenario ID' }, + executionId: { type: 'string', description: 'Execution ID' }, + }, + required: ['scenarioId', 'executionId'], + }, + }), + ]); + + const cmd = getCommand(program, 'executions', 'get'); + expect(cmd!.registeredArguments.map(a => a.name())).toEqual(['execution-id']); + + const scenarioOpt = getOption(program, 'executions', 'get', '--scenario-id'); + expect(scenarioOpt?.mandatory).toBe(true); + + const executionOpt = getOption(program, 'executions', 'get', '--execution-id'); + expect(executionOpt?.mandatory).toBe(false); + }); + + it('passes the positional value to execute under the original schema property name (with type coercion)', async () => { + const execute = jest.fn().mockResolvedValue({ ok: true }); + const program = buildProgramWithTool(makeTool({ execute })); + + const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + process.env.MAKE_API_KEY = 'test-key'; + process.env.MAKE_ZONE = 'eu1.make.com'; + + try { + await program.parseAsync(['data-structures', 'get', '178'], { from: 'user' }); + expect(execute).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ dataStructureId: 178 })); + } finally { + writeSpy.mockRestore(); + delete process.env.MAKE_API_KEY; + delete process.env.MAKE_ZONE; + } + }); + + it('still accepts the long-form flag and coerces the value identically', async () => { + const execute = jest.fn().mockResolvedValue({ ok: true }); + const program = buildProgramWithTool(makeTool({ execute })); + + const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + process.env.MAKE_API_KEY = 'test-key'; + process.env.MAKE_ZONE = 'eu1.make.com'; + + try { + await program.parseAsync(['data-structures', 'get', '--data-structure-id=178'], { from: 'user' }); + expect(execute).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ dataStructureId: 178 })); + } finally { + writeSpy.mockRestore(); + delete process.env.MAKE_API_KEY; + delete process.env.MAKE_ZONE; + } + }); + + it('errors when the resource id is supplied both positionally and via the flag', async () => { + const execute = jest.fn().mockResolvedValue({ ok: true }); + const program = buildProgramWithTool(makeTool({ execute })); + + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`__exit:${code ?? 0}__`); + }) as never); + + process.env.MAKE_API_KEY = 'test-key'; + process.env.MAKE_ZONE = 'eu1.make.com'; + + try { + await expect( + program.parseAsync(['data-structures', 'get', '178', '--data-structure-id=999'], { from: 'user' }), + ).rejects.toThrow('__exit:1__'); + + expect(execute).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('--data-structure-id was supplied both')); + } finally { + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + delete process.env.MAKE_API_KEY; + delete process.env.MAKE_ZONE; + } + }); + + it('errors when the resource id is required but neither positional nor flag is provided', async () => { + const execute = jest.fn().mockResolvedValue({ ok: true }); + const program = buildProgramWithTool(makeTool({ execute })); + + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`__exit:${code ?? 0}__`); + }) as never); + + process.env.MAKE_API_KEY = 'test-key'; + process.env.MAKE_ZONE = 'eu1.make.com'; + + try { + await expect(program.parseAsync(['data-structures', 'get'], { from: 'user' })).rejects.toThrow( + '__exit:1__', + ); + + expect(execute).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('missing required argument')); + } finally { + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + delete process.env.MAKE_API_KEY; + delete process.env.MAKE_ZONE; + } + }); + + it('renders the example with the resource id as a positional argument', () => { + const program = new Command(); + buildCommands(program, [makeTool({ examples: [{ dataStructureId: 178 }] })]); + + const cmd = getCommand(program, 'data-structures', 'get'); + let captured = ''; + cmd?.configureOutput({ writeOut: (s: string) => (captured += s) }); + cmd?.outputHelp(); + + expect(captured).toContain('make-cli data-structures get 178'); + expect(captured).not.toContain('--data-structure-id=178'); + expect(captured).not.toContain('--id=178'); + }); + + it('shows the positional argument in the Usage line and in an Arguments section', () => { + const program = new Command(); + buildCommands(program, [makeTool()]); + + const cmd = getCommand(program, 'data-structures', 'get'); + let captured = ''; + cmd?.configureOutput({ writeOut: (s: string) => (captured += s) }); + cmd?.outputHelp(); + + // Commander pads the Usage line with extra spaces when the root program + // has no name; match the meaningful bits with a loose regex. + expect(captured).toMatch(/Usage:\s+data-structures get \[options\] \[data-structure-id\]/); + expect(captured).toMatch(/Arguments:\s+data-structure-id\s+The data structure ID to retrieve/); + }); +}); + describe('CLI: formatOutput', () => { it('should pretty-print JSON by default', () => { const data = { key: 'value', num: 42 }; diff --git a/test/examples.spec.ts b/test/examples.spec.ts index 776af28..e05624e 100644 --- a/test/examples.spec.ts +++ b/test/examples.spec.ts @@ -201,4 +201,46 @@ describe('formatExampleCommand', () => { const lines = result.split('\n'); expect(lines[lines.length - 1]).not.toMatch(/\\$/); }); + + describe('with positional key', () => { + it('renders the positional value right after the command and drops the matching flag', () => { + const result = formatExampleCommand( + 'make-cli data-structures get', + { dataStructureId: 178 }, + 'dataStructureId', + ); + expect(result).toBe('make-cli data-structures get 178'); + }); + + it('keeps other keys as flags alongside the positional', () => { + const result = formatExampleCommand( + 'make-cli executions get', + { scenarioId: 925, executionId: 'abc' }, + 'executionId', + ); + expect(result).toBe('make-cli executions get abc --scenario-id=925'); + }); + + it('skips the positional when the example value for it is false', () => { + const result = formatExampleCommand( + 'make-cli scenarios run', + { scenarioId: false, responsive: true }, + 'scenarioId', + ); + // `false` on the positional key must be treated as "omit", matching + // the behavior of `false` on any other flag. It must NOT render as + // `make-cli scenarios run false`. + expect(result).toBe('make-cli scenarios run --responsive'); + }); + + it('falls back to flag-only rendering when the positional key is not present in the example', () => { + const result = formatExampleCommand('make-cli scenarios list', { teamId: 5 }, 'scenarioId'); + expect(result).toBe('make-cli scenarios list --team-id=5'); + }); + + it('is a no-op when positionalKey is undefined', () => { + const result = formatExampleCommand('make-cli scenarios list', { teamId: 5 }); + expect(result).toBe('make-cli scenarios list --team-id=5'); + }); + }); });