diff --git a/.agents/skills/commandly-tool-generation/SKILL.md b/.agents/skills/commandly-tool-generation/SKILL.md index 33cf6bf..440db58 100644 --- a/.agents/skills/commandly-tool-generation/SKILL.md +++ b/.agents/skills/commandly-tool-generation/SKILL.md @@ -17,7 +17,7 @@ Generate and edit CLI tool definitions in the Commandly flat JSON schema format. ### Parsing Help Text -1. Identify the tool name and any description/version info → populate `name`, `displayName`, `info`. +1. Identify the tool name and any description/version info → populate `binaryName`, `displayName`, optional `interactive`, and `info`. 2. Identify commands and subcommands → `commands[]` array with `key`, `name`, optional `parentCommandKey`. If the tool has no subcommands, leave `commands` as an empty array `[]`. 3. Map each flag/option/argument to a parameter → `parameters[]` array. 4. If commands exist, assign `commandKey` to non-global parameters. If no commands exist, parameters are **root parameters** — omit both `commandKey` and `isGlobal`. @@ -32,7 +32,7 @@ Generate and edit CLI tool definitions in the Commandly flat JSON schema format. ### Creating from Scratch -1. Use tool name as `name` (lowercase, hyphenated) and a display-friendly `displayName`. +1. Use the CLI binary as `binaryName` (lowercase, hyphenated where appropriate) and a display-friendly `displayName`. 2. If the tool has subcommands, add them to `commands[]`. If it has no subcommands, use `commands: []`. 3. Map all known parameters following the type rules below. @@ -44,23 +44,25 @@ Generate and edit CLI tool definitions in the Commandly flat JSON schema format. | `--output ` | `Option` | `String` | Add `longFlag`, optionally `shortFlag` | | `--count ` | `Option` | `Number` | | | `--format ` | `Option` | `Enum` | Use `enum.values[]` | -| `` | `Argument` | `String`/`Number` | Set `position` (1-based) | +| `` | `Argument` | `String`/`Number` | Set `position` (zero-based) | - **Short flag**: single dash + letter (e.g. `-o`). Include only if present. - **Long flag**: double dash + word (e.g. `--output`). Preserve exact prefix. -- **Aliases**: If a param has multiple forms, the first is `name`/primary flag, rest go in `aliases` (rare in Commandly; prefer `shortFlag` + `longFlag`). +- Do not invent extra alias fields. Use `shortFlag` and `longFlag` only; if the CLI exposes more variants than the schema supports, keep the canonical forms and mention the edge case in `description` only when it materially affects usage. ## Key Rules 1. Every `key` must be unique across the entire `parameters[]` array. It should be meaningful and derived from the parameter name or description. 2. When `commands` is non-empty: non-global parameters **must** have `commandKey`, global parameters **must** have `isGlobal: true` and no `commandKey`. 3. When `commands` is empty: parameters are **root parameters** — they must **not** have `commandKey` or `isGlobal`. Do not create a dummy command matching the tool name. -4. `name` should be user-friendly title case (e.g. `--output-file` → `"Output File"`). +4. Parameter `name` should be user-friendly title case (e.g. `--output-file` → `"Output File"`). Command `name` should match the actual CLI token used in the command path. 5. Descriptions in sentence case, trimmed. 6. Do not add `defaultValue` — it does not exist in the schema. -7. Do not add empty arrays/objects for optional properties (`validations`, `exclusionGroups`, `tags`, `dependencies`, `enum.values` when empty). -8. Tool description/version live under `info: { description, version, url }` — never at top level. `version` is **required** and must reflect the current release (no `v` prefix, e.g. `"1.9.0"` not `"v1.9.0"`). To find the latest version, call `GET https://api.github.com/repos/{owner}/{repo}/releases/latest` and use the `tag_name` field with the leading `v` stripped. For tools with non-standard tag formats (e.g. curl uses `curl-8_19_0`), use the release `name` field instead. For date-based versioning (e.g. yt-dlp uses `2026.03.17`), use `tag_name` as-is. -9. Output is pure JSON — no backticks, no trailing commas, proper indentation. +7. Do not add empty arrays/objects for optional properties (`validations`, `dependencies`, `exclusionGroups`, `metadata`, `enum`, `metadata.tags`). +8. Tool description/version live under `info: { description, version, url }` — never at top level. `version` is optional in the schema, but when you include it, use the current release string without a leading `v` unless the upstream project uses a non-semver date or custom release format. +9. Positional argument `position` is zero-based. +10. Persisted tool JSON files should include `$schema: "https://commandly.divyeshio.in/specification/flat.json"`. The validation script can auto-fix this when missing. +11. Output is pure JSON — no backticks, no trailing commas, proper indentation. ## Schema Reference diff --git a/.agents/skills/commandly-tool-generation/references/examples.md b/.agents/skills/commandly-tool-generation/references/examples.md index 9b444e0..adfecdd 100644 --- a/.agents/skills/commandly-tool-generation/references/examples.md +++ b/.agents/skills/commandly-tool-generation/references/examples.md @@ -7,7 +7,7 @@ Demonstrates: root parameters (no commands, no `commandKey`), Flag / Option / Ar ```json { "$schema": "https://commandly.divyeshio.in/specification/flat.json", - "name": "curl", + "binaryName": "curl", "displayName": "Curl", "info": { "description": "curl is a command line tool and library for transferring data with URLs.", @@ -22,7 +22,7 @@ Demonstrates: root parameters (no commands, no `commandKey`), Flag / Option / Ar "parameterType": "Argument", "dataType": "String", "isRequired": true, - "position": 1, + "position": 0, "sortOrder": 5 }, { @@ -73,7 +73,7 @@ Demonstrates: `dataType: "Enum"`, `enum.values[]`, `isRepeatable`. "dataType": "Enum", "isRepeatable": true, "shortFlag": "-iv", - "longFlag": "-ip-version", + "longFlag": "--ip-version", "enum": { "values": [ { @@ -98,37 +98,29 @@ Demonstrates: `dataType: "Enum"`, `enum.values[]`, `isRepeatable`. ## 3. Multi-command tool (git-style) -Demonstrates: multiple commands, `parentCommandKey` for subcommands, global parameters. +Demonstrates: multiple commands, command-scoped parameters, global parameters. ```json { "$schema": "https://commandly.divyeshio.in/specification/flat.json", - "name": "git", + "binaryName": "git", "displayName": "Git", "info": { "description": "Git is a free and open source distributed version control system.", "url": "https://git-scm.com/" }, "commands": [ - { - "key": "git", - "name": "git", - "isDefault": true, - "sortOrder": 1 - }, { "key": "commit", "name": "commit", "description": "Record changes to the repository.", - "parentCommandKey": "git", - "sortOrder": 2 + "sortOrder": 1 }, { "key": "push", "name": "push", "description": "Update remote refs.", - "parentCommandKey": "git", - "sortOrder": 3 + "sortOrder": 2 } ], "parameters": [ diff --git a/.agents/skills/commandly-tool-generation/references/schema.md b/.agents/skills/commandly-tool-generation/references/schema.md index 04300a5..833b8c9 100644 --- a/.agents/skills/commandly-tool-generation/references/schema.md +++ b/.agents/skills/commandly-tool-generation/references/schema.md @@ -2,15 +2,16 @@ ## Tool (root) -| Field | Type | Required | Notes | -| ----------------- | ---------------- | -------- | ---------------------------------------------- | -| `name` | string | ✓ | Lowercase, hyphenated CLI name (e.g. `"curl"`) | -| `displayName` | string | ✓ | Human-friendly title (e.g. `"Curl"`) | -| `info` | ToolInfo | | Description, version, URL | -| `commands` | Command[] | ✓ | Can be empty for tools with no subcommands | -| `parameters` | Parameter[] | ✓ | Can be empty array | -| `exclusionGroups` | ExclusionGroup[] | | Omit if unused | -| `metadata` | ToolMetadata | | Omit if unused | +| Field | Type | Required | Notes | +| ----------------- | ---------------- | -------- | --------------------------------------------- | +| `binaryName` | string | ✓ | Lowercase CLI binary name (e.g. `"curl"`) | +| `displayName` | string | ✓ | Human-friendly title (e.g. `"Curl"`) | +| `interactive` | boolean | | True if invoking the root tool opens a prompt | +| `info` | ToolInfo | | Description, version, URL | +| `commands` | Command[] | ✓ | Can be empty for tools with no subcommands | +| `parameters` | Parameter[] | ✓ | Can be empty array | +| `exclusionGroups` | ExclusionGroup[] | | Omit if unused | +| `metadata` | ToolMetadata | | Omit if unused | ## ToolInfo @@ -29,7 +30,6 @@ | `parentCommandKey` | string | | For subcommands; key of parent | | `description` | string | | | | `interactive` | boolean | | True if command opens interactive session | -| `isDefault` | boolean | | True for the root/default command | | `sortOrder` | number | | Display order | ## Parameter @@ -48,7 +48,7 @@ | `isGlobal` | boolean | | True if applies to all commands; must not be set when commands is empty | | `shortFlag` | string | | e.g. `"-o"`. Omit if none. | | `longFlag` | string | | e.g. `"--output"`. Preserve exact prefix. | -| `position` | number | | 1-based; only for `Argument` type | +| `position` | number | | Zero-based; only for `Argument` type | | `sortOrder` | number | | Display order | | `arraySeparator` | string | | For array-valued options | | `keyValueSeparator` | string | | `" "` or `"="` | @@ -121,7 +121,7 @@ Valid `exclusionType` values: `"mutual_exclusive"`, `"required_one_of"` ## $schema -Always include at the top of tool JSON files in the tools-collection: +Tool JSON files stored in `tools-collection` should include this top-level field. The validation script can inject it automatically: ```json "$schema": "https://commandly.divyeshio.in/specification/flat.json" diff --git a/.agents/skills/commandly-tool-generation/references/validation.md b/.agents/skills/commandly-tool-generation/references/validation.md index 9bcb26a..1374517 100644 --- a/.agents/skills/commandly-tool-generation/references/validation.md +++ b/.agents/skills/commandly-tool-generation/references/validation.md @@ -7,13 +7,13 @@ Use `scripts/validate-tool-collection.ts` to validate tool JSON files against th ### Usage ```bash -bun scripts/validate-tool-collection.ts public/tools-collection/.json +bun scripts/validate-tool-collection.ts tools-collection/.json ``` Multiple files at once: ```bash -bun scripts/validate-tool-collection.ts public/tools-collection/*.json +bun scripts/validate-tool-collection.ts tools-collection/*.json ``` ### What the script does @@ -21,32 +21,37 @@ bun scripts/validate-tool-collection.ts public/tools-collection/*.json 1. Reads each `.json` file passed as a CLI argument. 2. Parses the JSON — fails with a clear message on syntax errors. 3. Validates against `public/specification/flat.json` using AJV. -4. Checks that the `name` field matches the filename (e.g. `curl.json` must have `"name": "curl"`). -5. Checks that `commands` is a non-empty array. -6. Runs `sanitizeToolJSON` to strip `metadata` from parameters and inject `$schema`. -7. If the sanitized output differs from the file, **overwrites the file** with the corrected content and prints `✅ Fixed: `. +4. Checks that `binaryName` matches the filename (e.g. `curl.json` must have `"binaryName": "curl"`). +5. Runs repo-specific validation from `registry/commandly/utils/tool-validation.ts`. +6. Runs `fixTool(tool, { addSchema: true, removeMetadata: true })`. +7. If the fixed output differs from the file, **overwrites the file** with the corrected content and prints `✅ Fixed: `. 8. If already correct, prints `✅ OK: `. -9. Exits with code `1` and prints all errors if any validation fails. +9. Exits with code `1` and prints all schema or validation errors if any validation fails. ### Common errors and fixes -| Error | Fix | -| ------------------------------------------------------------------ | ---------------------------------------------------------------------- | -| `"name" field does not match filename` | Set `"name"` to the bare filename without extension | -| `"commands" must be a non-empty array` | Add at least one command; use the tool name as the default command key | -| Schema validation: `/ must have required property 'key'` | Every command and parameter needs a `key` field | -| Schema validation: `/ must have required property 'parameterType'` | Set `parameterType` to `"Flag"`, `"Option"`, or `"Argument"` | -| Schema validation: `/ must have required property 'dataType'` | Set `dataType` to `"Boolean"`, `"String"`, `"Number"`, or `"Enum"` | -| Invalid JSON | Fix syntax (trailing commas, missing quotes, etc.) | +| Error | Fix | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `"name" field (... ) does not match filename (...)` | Set `"binaryName"` to the bare filename without extension | +| Schema validation: `/ must have required property 'key'` | Every command and parameter needs a `key` field | +| Schema validation: `/ must have required property 'parameterType'` | Set `parameterType` to `"Flag"`, `"Option"`, or `"Argument"` | +| Schema validation: `/ must have required property 'dataType'` | Set `dataType` to `"Boolean"`, `"String"`, `"Number"`, or `"Enum"` | +| `Parameter "..." must have commandKey or isGlobal when commands exist` | Add `commandKey`, or mark the parameter as `isGlobal: true` | +| `Parameter "..." must not have commandKey or isGlobal when there are no commands` | Remove `commandKey` and `isGlobal` for root parameters | +| Invalid JSON | Fix syntax (trailing commas, missing quotes, etc.) | -### sanitizeToolJSON behaviour +### `fixTool` behaviour -`sanitizeToolJSON` (from `registry/commandly/utils/flat.ts`): +`fixTool` (from `registry/commandly/utils/flat.ts`) currently: -- Strips the `metadata` field from each parameter object. - Injects `"$schema": "https://commandly.divyeshio.in/specification/flat.json"` at the top level. +- Strips `metadata` from parameters when validation runs with `removeMetadata: true`. +- Removes top-level `metadata` when validation runs with `removeMetadata: true`. +- Removes empty `exclusionGroups`. +- Moves legacy top-level `description` and `version` into `info`. +- Removes redundant default values such as `false` booleans and the default option separator of a single space. -The script auto-applies this transform and writes back the file if needed, so generated JSON does not need to manually include `$schema` — the script will add it. +The script auto-applies this transform and writes back the file if needed, so generated JSON can omit `$schema` during drafting, but committed files should end up with it after validation. ## Validating in Code (TypeScript) @@ -54,6 +59,11 @@ The script auto-applies this transform and writes back the file if needed, so ge import { readFileSync } from "fs"; import { resolve } from "path"; import Ajv from "ajv"; +import { + validateTool, + hasErrors, + formatValidationErrors, +} from "@/components/commandly/utils/tool-validation"; const schema = JSON.parse(readFileSync(resolve("public/specification/flat.json"), "utf-8")); const ajv = new Ajv({ allErrors: true }); @@ -63,4 +73,9 @@ const tool = JSON.parse(readFileSync("my-tool.json", "utf-8")); if (!validate(tool)) { console.error(validate.errors); } + +const toolErrors = validateTool(tool); +if (hasErrors(toolErrors)) { + console.error(formatValidationErrors(toolErrors)); +} ``` diff --git a/README.md b/README.md index e58ff5b..46c7db1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ commandly -

CLI -> UI. A user-friendly way to generate CLI commands using UI. Quickly turn help text from any CLI tool into UI using AI.

+

CLI -> UI. Browse tool definitions, edit command schemas, and generate runnable CLI commands from a visual interface.

Features • @@ -16,7 +16,6 @@

commandly-ui - commandly-tool-editor

@@ -27,18 +26,23 @@ - JSON Output - Nested, Flat. - Generate Help Menu - Generate Command -- Saved Commands - using localstorage +- Saved Commands - Exclusion Groups - JSON specification - AI Generation - Quickly turn help text from any CLI tool into UI. +## Specification + +- [Flat](https://commandly.divyeshio.in/docs/specification-schema) +- [Nested](https://commandly.divyeshio.in/docs/specification-nested) + ## 🎯 Motivation -Complex CLI tools with tons of commands and options can be overwhelming. Instead of wrestling with documentation or asking ChatGPT, why not just use a UI? +Complex CLI tools with tons of commands and options can be overwhelming. Commandly gives those tools a visual layer so you can browse them, edit them, and generate the exact command you need without memorizing every flag. -LLMs work best with structured data. Imagine having all your CLI commands and options neatly organized in a single JSON file. +LLMs work best with structured data. Commandly keeps CLI definitions in a structured JSON format that is easy to inspect, validate, contribute, and reuse. -Plus, building this to work with MCPs. Eventually, LLMs should be able to access all these tool details whenever they need them. +The same structure also makes the project useful for MCP and agent workflows, where tools need a reliable schema instead of loose help text. ## 💪🏻 Contributing @@ -46,9 +50,9 @@ Development - Please read the [contributing guide](/CONTRIBUTING.md). For adding new tools: -1. Create/Design tool locally -2. Copy **Flat** JSON Output -3. Raise a PR, adding JSON file to public/tools-collection. +1. Create or edit a tool in the browser +2. Copy the **Flat** JSON output +3. Raise a PR with the JSON file in `tools-collection` ## 📜 License diff --git a/public/images/tool-editor-dark.png b/public/images/tool-editor-dark.png deleted file mode 100644 index d1c055b..0000000 Binary files a/public/images/tool-editor-dark.png and /dev/null differ diff --git a/public/images/tool-editor.png b/public/images/tool-editor.png deleted file mode 100644 index 6476d82..0000000 Binary files a/public/images/tool-editor.png and /dev/null differ diff --git a/public/images/ui-dark.png b/public/images/ui-dark.png deleted file mode 100644 index eaeb998..0000000 Binary files a/public/images/ui-dark.png and /dev/null differ diff --git a/public/images/ui.png b/public/images/ui.png index 9c5fa8d..f3f8d61 100644 Binary files a/public/images/ui.png and b/public/images/ui.png differ diff --git a/public/r/generated-command.json b/public/r/generated-command.json index 7d04991..6e78377 100644 --- a/public/r/generated-command.json +++ b/public/r/generated-command.json @@ -14,7 +14,7 @@ "files": [ { "path": "registry/commandly/generated-command.tsx", - "content": "import { Parameter, ParameterValue, Tool, Command } from \"@/components/commandly/types/flat\";\nimport { getCommandPath } from \"@/components/commandly/utils/flat\";\nimport { Button } from \"@/components/ui/button\";\nimport { TerminalIcon, CopyIcon, SaveIcon } from \"lucide-react\";\nimport { useCallback, useEffect, useState, useMemo } from \"react\";\nimport { toast } from \"sonner\";\n\ninterface GeneratedCommandProps {\n tool: Tool;\n selectedCommand?: Command | null;\n parameterValues: Record;\n onSaveCommand?: (command: string) => void;\n}\n\nexport function GeneratedCommand({\n tool,\n selectedCommand: providedCommand,\n parameterValues,\n onSaveCommand,\n}: GeneratedCommandProps) {\n const selectedCommand = providedCommand === undefined ? tool.commands[0] : providedCommand;\n const hasCommands = tool.commands.length > 0;\n const [generatedCommand, setGeneratedCommand] = useState(\"\");\n\n const globalParameters = useMemo(() => {\n return tool.parameters?.filter((p) => p.isGlobal) || [];\n }, [tool]);\n\n const rootParameters = useMemo(() => {\n if (hasCommands && selectedCommand) return [];\n return tool.parameters?.filter((p) => !p.commandKey && !p.isGlobal) || [];\n }, [tool, hasCommands, selectedCommand]);\n\n const currentParameters = useMemo(() => {\n if (!selectedCommand) return [];\n return tool?.parameters?.filter((p) => p.commandKey === selectedCommand?.key) || [];\n }, [tool, selectedCommand]);\n\n const generateCommand = useCallback(() => {\n let command = tool.binaryName;\n\n if (hasCommands && selectedCommand) {\n const commandPath = getCommandPath(selectedCommand, tool);\n if (tool.binaryName !== commandPath) {\n command = `${tool.binaryName} ${commandPath}`;\n }\n }\n\n const parametersWithValues: Array<{\n param: Parameter;\n value: ParameterValue;\n }> = [];\n\n globalParameters.forEach((param) => {\n const value = parameterValues[param.key];\n if (value !== undefined && value !== \"\" && value !== false) {\n parametersWithValues.push({ param, value });\n }\n });\n\n rootParameters.forEach((param) => {\n const value = parameterValues[param.key];\n if (value !== undefined && value !== \"\" && value !== false) {\n parametersWithValues.push({ param, value });\n }\n });\n\n currentParameters.forEach((param) => {\n const value = parameterValues[param.key];\n if (value !== undefined && value !== \"\" && value !== false && !param.isGlobal) {\n parametersWithValues.push({ param, value });\n }\n });\n\n const positionalParams = parametersWithValues\n .filter(({ param }) => param.parameterType === \"Argument\")\n .sort((a, b) => (a.param.position || 0) - (b.param.position || 0));\n\n parametersWithValues.forEach(({ param, value }) => {\n if (param.parameterType === \"Flag\") {\n if (value === true) {\n const flag = param.shortFlag || param.longFlag;\n if (flag) command += ` ${flag}`;\n } else if (param.isRepeatable && typeof value === \"number\" && value > 0) {\n const flag = param.shortFlag || param.longFlag;\n if (flag) command += ` ${flag}`.repeat(value);\n }\n } else if (param.parameterType === \"Option\") {\n const flag = param.shortFlag || param.longFlag;\n if (flag) {\n const separator = param.keyValueSeparator ?? \" \";\n if (Array.isArray(value)) {\n const entries = value.filter((v) => v !== \"\");\n if (entries.length > 0) {\n if (param.arraySeparator) {\n command += ` ${flag}${separator}${entries.join(param.arraySeparator)}`;\n } else {\n entries.forEach((v) => {\n command += ` ${flag}${separator}${v}`;\n });\n }\n }\n } else {\n command += ` ${flag}${separator}${value}`;\n }\n }\n }\n });\n\n positionalParams.forEach(({ value }) => {\n if (!Array.isArray(value)) {\n command += ` ${value}`;\n }\n });\n\n setGeneratedCommand(command);\n }, [\n tool,\n parameterValues,\n selectedCommand,\n hasCommands,\n globalParameters,\n rootParameters,\n currentParameters,\n ]);\n\n useEffect(() => {\n generateCommand();\n }, [generateCommand]);\n\n const copyCommand = () => {\n navigator.clipboard.writeText(generatedCommand);\n toast(\"Command copied!\");\n };\n\n return (\n
\n {generatedCommand ? (\n
\n
\n
{generatedCommand}
\n
\n
\n \n \n Copy Command\n \n {onSaveCommand && (\n onSaveCommand(generatedCommand)}\n variant=\"outline\"\n className=\"w-full sm:flex-1\"\n >\n \n Save Command\n \n )}\n
\n
\n ) : (\n
\n \n

Configure parameters to generate the command.

\n
\n )}\n
\n );\n}\n", + "content": "import { ParameterValue, Tool, Command } from \"@/components/commandly/types/flat\";\nimport { generateCommand } from \"@/components/commandly/utils/flat\";\nimport { Button } from \"@/components/ui/button\";\nimport { CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\nimport { TerminalIcon, CopyIcon, SaveIcon } from \"lucide-react\";\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport { toast } from \"sonner\";\n\ninterface GeneratedCommandProps {\n tool: Tool;\n selectedCommand?: Command | null;\n parameterValues: Record;\n onSaveCommand?: (command: string) => void;\n useLongFlag?: boolean;\n children?: ReactNode;\n}\n\ninterface GeneratedCommandContextValue {\n generatedCommand: string;\n useLongFlag: boolean;\n setUseLongFlag: (value: boolean) => void;\n supportsFlagPreference: boolean;\n onCopyCommand: () => void;\n onSaveCommand?: (command: string) => void;\n}\n\nconst GeneratedCommandContext = createContext(null);\n\nfunction useGeneratedCommandContext() {\n const context = useContext(GeneratedCommandContext);\n\n if (!context) {\n throw new Error(\"GeneratedCommand compound components must be used within GeneratedCommand.\");\n }\n\n return context;\n}\n\nfunction GeneratedCommandRoot({\n tool,\n selectedCommand: providedCommand,\n parameterValues,\n onSaveCommand,\n useLongFlag = false,\n children,\n}: GeneratedCommandProps) {\n const [prefersLongFlag, setPrefersLongFlag] = useState(useLongFlag);\n const supportsFlagPreference = useMemo(\n () =>\n tool.parameters.some(\n (parameter) =>\n parameter.parameterType !== \"Argument\" &&\n Boolean(parameter.shortFlag) &&\n Boolean(parameter.longFlag),\n ),\n [tool.parameters],\n );\n\n useEffect(() => {\n setPrefersLongFlag(useLongFlag);\n }, [useLongFlag]);\n\n const generatedCommand = useMemo(\n () =>\n generateCommand(tool, parameterValues, {\n selectedCommand: providedCommand,\n useLongFlag: prefersLongFlag,\n }),\n [tool, parameterValues, providedCommand, prefersLongFlag],\n );\n\n const copyCommand = useCallback(() => {\n navigator.clipboard.writeText(generatedCommand);\n toast(\"Command copied!\");\n }, [generatedCommand]);\n\n const contextValue = useMemo(\n () => ({\n generatedCommand,\n useLongFlag: prefersLongFlag,\n setUseLongFlag: setPrefersLongFlag,\n supportsFlagPreference,\n onCopyCommand: copyCommand,\n onSaveCommand,\n }),\n [generatedCommand, prefersLongFlag, supportsFlagPreference, copyCommand, onSaveCommand],\n );\n\n return (\n \n {generatedCommand ? (\n (children ?? (\n
\n \n \n
\n ))\n ) : (\n \n )}\n
\n );\n}\n\nfunction GeneratedCommandToolbar({ className, ...props }: ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction GeneratedCommandHeader({\n children,\n className,\n ...props\n}: ComponentProps) {\n return (\n \n \n \n Generated Command\n \n {children}\n \n );\n}\n\nfunction GeneratedCommandFlagPreference({\n className,\n ...props\n}: Omit, \"checked\" | \"onCheckedChange\">) {\n const { supportsFlagPreference, useLongFlag, setUseLongFlag } = useGeneratedCommandContext();\n\n if (!supportsFlagPreference) return null;\n\n return (\n \n );\n}\n\nfunction GeneratedCommandOutput({ className, ...props }: ComponentProps<\"div\">) {\n const { generatedCommand } = useGeneratedCommandContext();\n\n return (\n \n
{generatedCommand}
\n \n );\n}\n\nfunction GeneratedCommandActions({ className, ...props }: ComponentProps<\"div\">) {\n const { generatedCommand, onCopyCommand, onSaveCommand } = useGeneratedCommandContext();\n\n return (\n \n \n \n Copy Command\n \n {onSaveCommand && (\n onSaveCommand(generatedCommand)}\n variant=\"outline\"\n className=\"w-full sm:flex-1\"\n >\n \n Save Command\n \n )}\n \n );\n}\n\nfunction GeneratedCommandEmptyState() {\n return (\n
\n \n

Configure parameters to generate the command.

\n
\n );\n}\n\nexport const GeneratedCommand = Object.assign(GeneratedCommandRoot, {\n Header: GeneratedCommandHeader,\n Toolbar: GeneratedCommandToolbar,\n FlagPreference: GeneratedCommandFlagPreference,\n Output: GeneratedCommandOutput,\n Actions: GeneratedCommandActions,\n EmptyState: GeneratedCommandEmptyState,\n});\n", "type": "registry:component", "target": "components/commandly/generated-command.tsx" }, @@ -32,7 +32,7 @@ }, { "path": "registry/commandly/utils/flat.ts", - "content": "import type { Command, Parameter, Tool } from \"@/components/commandly/types/flat\";\n\nexport const slugify = (text: string): string => {\n return text\n .toString()\n .toLowerCase()\n .trim()\n .replace(/\\s+/g, \"-\") // Replace spaces with -\n .replace(/[^\\w-]+/g, \"\") // Remove all non-word chars\n .replace(/--+/g, \"-\") // Replace multiple - with single -\n .replace(/^-+/, \"\") // Trim - from start of text\n .replace(/-+$/, \"\"); // Trim - from end of text\n};\n\nexport const getCommandPath = (command: Command, tool: Tool): string => {\n const allCommands = tool.commands;\n const findCommandPath = (\n targetKey: string,\n commands: Command[],\n path: string[] = [],\n ): string[] | null => {\n for (const cmd of commands) {\n if (cmd.name === targetKey) {\n return [...path, cmd.name];\n }\n\n const childCommands = allCommands.filter((c) => c.parentCommandKey === cmd.key);\n if (childCommands.length > 0) {\n const subPath = findCommandPath(targetKey, childCommands, [...path, cmd.name]);\n if (subPath) {\n return subPath;\n }\n }\n }\n return null;\n };\n\n const rootCommands = tool.commands.filter((c) => !c.parentCommandKey);\n const path = findCommandPath(command.name, rootCommands);\n\n if (!path) return command.name;\n\n return path.join(\" \");\n};\n\nexport const getAllSubcommands = (commandKey: string, commands: Command[]): Command[] => {\n const result: Command[] = [];\n\n const findSubcommands = (parentKey: string) => {\n commands.forEach((cmd) => {\n if (cmd.parentCommandKey === parentKey) {\n result.push(cmd);\n findSubcommands(cmd.key);\n }\n });\n };\n\n findSubcommands(commandKey);\n return result;\n};\n\nconst SCHEMA_URL = \"https://commandly.divyeshio.in/specification/flat.json\";\n\nexport const sanitizeToolJSON = (tool: Tool) => {\n const parameters = tool.parameters.map(({ metadata: _metadata, ...param }) => param);\n\n return {\n $schema: SCHEMA_URL,\n ...tool,\n parameters,\n };\n};\n\nexport const exportToStructuredJSON = (tool: Tool) => {\n return {\n $schema: SCHEMA_URL,\n name: tool.binaryName,\n displayName: tool.displayName,\n info: tool.info,\n commands: tool.commands.map((cmd) => ({ ...cmd })),\n parameters: tool.parameters.map(({ metadata: _metadata, ...param }) => param),\n exclusionGroups: tool.exclusionGroups,\n metadata: tool.metadata,\n };\n};\n\nexport const createNewParameter = (isGlobal: boolean, commandKey?: string): Parameter => {\n return {\n key: \"\",\n name: \"\",\n commandKey: isGlobal ? undefined : commandKey,\n parameterType: \"Option\",\n dataType: \"String\",\n ...(isGlobal ? { isGlobal: true } : {}),\n longFlag: \"\",\n };\n};\n\nconst isEmpty = (value: object | null | undefined): boolean => {\n if (value == null) return true;\n if (Array.isArray(value)) return value.length === 0;\n return Object.keys(value).length === 0;\n};\n\nconst cleanParameter = (param: Parameter): Parameter => {\n const cleaned = { ...param };\n\n if (!cleaned.enum || cleaned.enum.values.length === 0) delete cleaned.enum;\n if (isEmpty(cleaned.validations)) delete cleaned.validations;\n if (isEmpty(cleaned.dependencies)) delete cleaned.dependencies;\n\n if (cleaned.metadata) {\n const meta = { ...cleaned.metadata };\n if (isEmpty(meta.tags)) delete meta.tags;\n if (isEmpty(meta)) {\n delete cleaned.metadata;\n } else {\n cleaned.metadata = meta;\n }\n }\n\n return cleaned;\n};\n\nexport const cleanupTool = (tool: Tool): Tool => {\n const cleaned = { ...tool };\n\n if (isEmpty(cleaned.exclusionGroups)) delete cleaned.exclusionGroups;\n if (isEmpty(cleaned.metadata)) delete cleaned.metadata;\n\n cleaned.parameters = cleaned.parameters.map(cleanParameter);\n\n return cleaned;\n};\n", + "content": "import { SCHEMA_URL } from \"@/components/ai-chat/tool-rules\";\nimport type {\n Command,\n ExclusionGroup,\n Parameter,\n ParameterMetadata,\n ParameterValue,\n Tool,\n} from \"@/components/commandly/types/flat\";\n\nexport const slugify = (text: string): string => {\n return text\n .toString()\n .toLowerCase()\n .trim()\n .replace(/\\s+/g, \"-\") // Replace spaces with -\n .replace(/[^\\w-]+/g, \"\") // Remove all non-word chars\n .replace(/--+/g, \"-\") // Replace multiple - with single -\n .replace(/^-+/, \"\") // Trim - from start of text\n .replace(/-+$/, \"\"); // Trim - from end of text\n};\n\nexport const getCommandPath = (command: Command, tool: Tool): string => {\n const allCommands = tool.commands;\n const findCommandPath = (\n targetKey: string,\n commands: Command[],\n path: string[] = [],\n ): string[] | null => {\n for (const cmd of commands) {\n if (cmd.name === targetKey) {\n return [...path, cmd.name];\n }\n\n const childCommands = allCommands.filter((c) => c.parentCommandKey === cmd.key);\n if (childCommands.length > 0) {\n const subPath = findCommandPath(targetKey, childCommands, [...path, cmd.name]);\n if (subPath) {\n return subPath;\n }\n }\n }\n return null;\n };\n\n const rootCommands = tool.commands.filter((c) => !c.parentCommandKey);\n const path = findCommandPath(command.name, rootCommands);\n\n if (!path) return command.name;\n\n return path.join(\" \");\n};\n\nexport const getAllSubcommands = (commandKey: string, commands: Command[]): Command[] => {\n const result: Command[] = [];\n\n const findSubcommands = (parentKey: string) => {\n commands.forEach((cmd) => {\n if (cmd.parentCommandKey === parentKey) {\n result.push(cmd);\n findSubcommands(cmd.key);\n }\n });\n };\n\n findSubcommands(commandKey);\n return result;\n};\n\nexport interface GenerateCommandOptions {\n selectedCommand?: Command | null;\n useLongFlag?: boolean;\n}\n\nfunction getPreferredFlag(param: Parameter, useLongFlag: boolean): string | undefined {\n return useLongFlag ? param.longFlag || param.shortFlag : param.shortFlag || param.longFlag;\n}\n\nexport function generateCommand(\n tool: Tool,\n parameterValues: Record,\n options: GenerateCommandOptions = {},\n): string {\n const hasCommands = tool.commands.length > 0;\n const selectedCommand =\n options.selectedCommand === undefined ? (tool.commands[0] ?? null) : options.selectedCommand;\n const useLongFlag = options.useLongFlag ?? false;\n\n let command = tool.binaryName;\n\n if (hasCommands && selectedCommand) {\n const commandPath = getCommandPath(selectedCommand, tool);\n if (tool.binaryName !== commandPath) {\n command = `${tool.binaryName} ${commandPath}`;\n }\n }\n\n const parametersWithValues: Array<{\n param: Parameter;\n value: ParameterValue;\n }> = [];\n const globalParameters = tool.parameters?.filter((param) => param.isGlobal) ?? [];\n const rootParameters =\n hasCommands && selectedCommand\n ? []\n : (tool.parameters?.filter((param) => !param.commandKey && !param.isGlobal) ?? []);\n const currentParameters = selectedCommand\n ? (tool.parameters?.filter(\n (param) => param.commandKey === selectedCommand.key && !param.isGlobal,\n ) ?? [])\n : [];\n\n [...globalParameters, ...rootParameters, ...currentParameters].forEach((param) => {\n const value = parameterValues[param.key];\n if (value !== undefined && value !== \"\" && value !== false) {\n parametersWithValues.push({ param, value });\n }\n });\n\n const positionalParams = parametersWithValues\n .filter(({ param }) => param.parameterType === \"Argument\")\n .sort((a, b) => (a.param.position || 0) - (b.param.position || 0));\n\n parametersWithValues.forEach(({ param, value }) => {\n if (param.parameterType === \"Flag\") {\n if (value === true) {\n const flag = getPreferredFlag(param, useLongFlag);\n if (flag) command += ` ${flag}`;\n } else if (param.isRepeatable && typeof value === \"number\" && value > 0) {\n const flag = getPreferredFlag(param, useLongFlag);\n if (flag) command += ` ${flag}`.repeat(value);\n }\n return;\n }\n\n if (param.parameterType === \"Option\") {\n const flag = getPreferredFlag(param, useLongFlag);\n if (!flag) return;\n\n const separator = param.keyValueSeparator ?? \" \";\n if (Array.isArray(value)) {\n const entries = value.filter((entry) => entry !== \"\");\n if (entries.length === 0) return;\n\n if (param.arraySeparator) {\n command += ` ${flag}${separator}${entries.join(param.arraySeparator)}`;\n return;\n }\n\n entries.forEach((entry) => {\n command += ` ${flag}${separator}${entry}`;\n });\n return;\n }\n\n command += ` ${flag}${separator}${value}`;\n }\n });\n\n positionalParams.forEach(({ value }) => {\n if (!Array.isArray(value)) {\n command += ` ${value}`;\n }\n });\n\n return command;\n}\n\nexport const exportToStructuredJSON = (tool: Tool) => {\n return {\n $schema: SCHEMA_URL,\n name: tool.binaryName,\n displayName: tool.displayName,\n info: tool.info,\n commands: tool.commands.map((cmd) => ({ ...cmd })),\n parameters: tool.parameters.map(({ metadata: _metadata, ...param }) => param),\n exclusionGroups: tool.exclusionGroups,\n metadata: tool.metadata,\n };\n};\n\nfunction isEmptyArray(value: unknown): boolean {\n return Array.isArray(value) && value.length === 0;\n}\n\nfunction isEmptyObject(value: unknown): boolean {\n return (\n value != null &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n Object.keys(value).length === 0\n );\n}\n\nfunction cleanParameter(param: Parameter): Parameter {\n const cleaned = { ...param };\n\n if (cleaned.isRequired === false) delete cleaned.isRequired;\n if (cleaned.isRepeatable === false) delete cleaned.isRepeatable;\n if (cleaned.isGlobal === false) delete cleaned.isGlobal;\n if (cleaned.keyValueSeparator === \" \") delete cleaned.keyValueSeparator;\n if (cleaned.arraySeparator === \",\" && cleaned.isRepeatable !== true)\n delete cleaned.arraySeparator;\n\n if (!cleaned.enum || isEmptyArray(cleaned.enum.values)) delete cleaned.enum;\n if (isEmptyArray(cleaned.validations)) delete cleaned.validations;\n if (isEmptyArray(cleaned.dependencies)) delete cleaned.dependencies;\n\n if (cleaned.metadata) {\n const meta = { ...cleaned.metadata } as ParameterMetadata;\n if (isEmptyArray(meta.tags)) delete meta.tags;\n if (isEmptyObject(meta)) {\n delete cleaned.metadata;\n } else {\n cleaned.metadata = meta;\n }\n }\n\n return cleaned;\n}\n\nfunction cleanCommand(cmd: Command): Command {\n const cleaned = { ...cmd };\n if (cleaned.interactive === false) delete cleaned.interactive;\n return cleaned;\n}\n\nfunction cleanExclusionGroup(group: ExclusionGroup): ExclusionGroup {\n return { ...group };\n}\n\nexport interface FixToolOptions {\n addSchema?: boolean;\n removeMetadata?: boolean;\n}\n\nexport function fixTool(tool: Tool, options?: FixToolOptions): Tool {\n const addSchema = options?.addSchema ?? false;\n const removeMetadata = options?.removeMetadata ?? false;\n\n const cleaned: Record = { ...tool };\n\n if (addSchema) {\n cleaned[\"$schema\"] = SCHEMA_URL;\n }\n\n if (cleaned.interactive === false) delete cleaned.interactive;\n if (isEmptyObject(cleaned.metadata) || removeMetadata) delete cleaned.metadata;\n if (isEmptyArray(cleaned.exclusionGroups)) delete cleaned.exclusionGroups;\n\n if (\"description\" in cleaned && cleaned.info == null) {\n cleaned.info = { description: cleaned.description as string };\n delete cleaned.description;\n }\n\n if (\"version\" in cleaned && typeof cleaned.version === \"string\") {\n if (cleaned.info && typeof cleaned.info === \"object\") {\n (cleaned.info as Record).version = cleaned.version;\n } else {\n cleaned.info = { version: cleaned.version as string };\n }\n delete cleaned.version;\n }\n\n if (Array.isArray(cleaned.commands)) {\n cleaned.commands = (cleaned.commands as Command[]).map(cleanCommand);\n }\n\n if (Array.isArray(cleaned.parameters)) {\n cleaned.parameters = (cleaned.parameters as Parameter[]).map((p) => {\n const fixed = cleanParameter(p);\n if (removeMetadata) delete fixed.metadata;\n return fixed;\n });\n }\n\n if (\n Array.isArray(cleaned.exclusionGroups) &&\n (cleaned.exclusionGroups as ExclusionGroup[]).length > 0\n ) {\n cleaned.exclusionGroups = (cleaned.exclusionGroups as ExclusionGroup[]).map(\n cleanExclusionGroup,\n );\n }\n\n return cleaned as unknown as Tool;\n}\n", "type": "registry:file", "target": "components/commandly/utils/flat.ts" }, diff --git a/public/r/json-output.json b/public/r/json-output.json index 5145302..39adee6 100644 --- a/public/r/json-output.json +++ b/public/r/json-output.json @@ -13,7 +13,7 @@ "files": [ { "path": "registry/commandly/json-output.tsx", - "content": "import { Tool } from \"@/components/commandly/types/flat\";\nimport { exportToStructuredJSON } from \"@/components/commandly/utils/flat\";\nimport { convertToNestedStructure } from \"@/components/commandly/utils/nested\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardAction, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport {\n Command as UICommand,\n CommandGroup,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport { CheckIcon, ChevronsUpDownIcon, CopyIcon, Edit2Icon, XIcon } from \"lucide-react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nconst jsonOptions = [\n { value: \"nested\", label: \"Nested\" },\n { value: \"flat\", label: \"Flat\" },\n];\n\ntype DiffLine = { type: \"same\" | \"added\" | \"removed\"; text: string };\n\nfunction diffLines(before: string, after: string): DiffLine[] {\n const a = before.split(\"\\n\");\n const b = after.split(\"\\n\");\n const m = a.length;\n const n = b.length;\n const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));\n for (let i = 1; i <= m; i++)\n for (let j = 1; j <= n; j++)\n dp[i][j] =\n a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);\n const result: DiffLine[] = [];\n let i = m,\n j = n;\n while (i > 0 || j > 0) {\n if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {\n result.unshift({ type: \"same\", text: a[i - 1] });\n i--;\n j--;\n } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {\n result.unshift({ type: \"added\", text: b[j - 1] });\n j--;\n } else {\n result.unshift({ type: \"removed\", text: a[i - 1] });\n i--;\n }\n }\n return result;\n}\n\ninterface JsonTypeComponentProps {\n tool: Tool;\n originalTool?: Tool;\n onApply?: (tool: Tool) => void;\n}\n\nexport function JsonOutput({ tool, originalTool, onApply }: JsonTypeComponentProps) {\n const [open, setOpen] = useState(false);\n const [jsonString, setJsonString] = useState();\n const [originalJsonString, setOriginalJsonString] = useState();\n const [jsonType, setJsonType] = useState<\"nested\" | \"flat\">(\"flat\");\n const [isEditing, setIsEditing] = useState(false);\n const [editValue, setEditValue] = useState(\"\");\n const [showDiff, setShowDiff] = useState(true);\n\n useEffect(() => {\n const config =\n jsonType === \"flat\" ? exportToStructuredJSON(tool) : convertToNestedStructure(tool);\n setJsonString(JSON.stringify(config, null, 2));\n }, [jsonType, tool]);\n\n useEffect(() => {\n if (!originalTool) {\n setOriginalJsonString(undefined);\n return;\n }\n const config =\n jsonType === \"flat\"\n ? exportToStructuredJSON(originalTool)\n : convertToNestedStructure(originalTool);\n setOriginalJsonString(JSON.stringify(config, null, 2));\n }, [jsonType, originalTool]);\n\n const diff = useMemo(() => {\n if (!originalJsonString || !jsonString || originalJsonString === jsonString) return null;\n return diffLines(originalJsonString, jsonString);\n }, [originalJsonString, jsonString]);\n\n const diffStats = useMemo(() => {\n if (!diff) return null;\n const added = diff.filter((l) => l.type === \"added\").length;\n const removed = diff.filter((l) => l.type === \"removed\").length;\n return { added, removed };\n }, [diff]);\n\n const handleEditToggle = () => {\n setEditValue(jsonString ?? \"\");\n setIsEditing(true);\n };\n\n const handleApply = () => {\n try {\n const parsed = JSON.parse(editValue) as Tool;\n onApply!(parsed);\n setIsEditing(false);\n } catch {\n toast.error(\"Invalid JSON\", { description: \"Please fix the JSON before applying.\" });\n }\n };\n\n const handleCancel = () => {\n setIsEditing(false);\n setEditValue(\"\");\n };\n\n return (\n \n \n \n
\n Output type:\n \n \n \n {jsonOptions.find((option) => option.value === jsonType)?.label}\n \n \n \n \n \n \n \n {jsonOptions.map((option) => (\n {\n setJsonType(currentValue as \"nested\" | \"flat\");\n setOpen(false);\n }}\n >\n {option.label}\n \n \n ))}\n \n \n \n \n \n
\n
\n
\n {onApply && !isEditing && (\n \n \n \n )}\n {onApply && isEditing && (\n \n \n \n )}\n {\n navigator.clipboard.writeText(jsonString!);\n toast(\"Copied!\");\n }}\n >\n \n \n
\n
\n \n {diffStats && !isEditing && (\n
\n {diffStats.added > 0 && (\n \n +{diffStats.added} added\n \n )}\n {diffStats.removed > 0 && (\n \n -{diffStats.removed} removed\n \n )}\n setShowDiff((v) => !v)}\n >\n {showDiff ? \"Full view\" : \"Diff view\"}\n \n
\n )}\n {isEditing ? (\n
\n \n setEditValue(e.target.value)}\n spellCheck={false}\n />\n \n \n \n
\n \n Cancel\n \n \n Apply\n \n
\n
\n ) : diff && showDiff ? (\n \n
\n              {diff.map((line, idx) => (\n                \n                  \n                    {line.type === \"added\" ? \"+ \" : line.type === \"removed\" ? \"- \" : \"  \"}\n                  \n                  {line.text}\n                \n              ))}\n            
\n \n \n \n ) : (\n \n
\n              {jsonString}\n            
\n \n \n \n )}\n
\n
\n );\n}\n", + "content": "import { Tool } from \"@/components/commandly/types/flat\";\nimport { exportToStructuredJSON } from \"@/components/commandly/utils/flat\";\nimport { convertToNestedStructure } from \"@/components/commandly/utils/nested\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardAction, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport {\n Command as UICommand,\n CommandGroup,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport { CheckIcon, ChevronsUpDownIcon, CopyIcon, Edit2Icon, XIcon } from \"lucide-react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nconst jsonOptions = [\n { value: \"nested\", label: \"Nested\" },\n { value: \"flat\", label: \"Flat\" },\n];\n\ntype DiffLine = { type: \"same\" | \"added\" | \"removed\"; text: string };\n\nfunction diffLines(before: string, after: string): DiffLine[] {\n const a = before.split(\"\\n\");\n const b = after.split(\"\\n\");\n const m = a.length;\n const n = b.length;\n const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));\n for (let i = 1; i <= m; i++)\n for (let j = 1; j <= n; j++)\n dp[i][j] =\n a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);\n const result: DiffLine[] = [];\n let i = m,\n j = n;\n while (i > 0 || j > 0) {\n if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {\n result.unshift({ type: \"same\", text: a[i - 1] });\n i--;\n j--;\n } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {\n result.unshift({ type: \"added\", text: b[j - 1] });\n j--;\n } else {\n result.unshift({ type: \"removed\", text: a[i - 1] });\n i--;\n }\n }\n return result;\n}\n\ninterface JsonTypeComponentProps {\n tool: Tool;\n originalTool?: Tool;\n onApply?: (tool: Tool) => void;\n}\n\nexport function JsonOutput({ tool, originalTool, onApply }: JsonTypeComponentProps) {\n const [open, setOpen] = useState(false);\n const [jsonString, setJsonString] = useState();\n const [originalJsonString, setOriginalJsonString] = useState();\n const [jsonType, setJsonType] = useState<\"nested\" | \"flat\">(\"flat\");\n const [isEditing, setIsEditing] = useState(false);\n const [editValue, setEditValue] = useState(\"\");\n const [showDiff, setShowDiff] = useState(true);\n\n useEffect(() => {\n const config =\n jsonType === \"flat\" ? exportToStructuredJSON(tool) : convertToNestedStructure(tool);\n setJsonString(JSON.stringify(config, null, 2));\n }, [jsonType, tool]);\n\n useEffect(() => {\n if (!originalTool) {\n setOriginalJsonString(undefined);\n return;\n }\n const config =\n jsonType === \"flat\"\n ? exportToStructuredJSON(originalTool)\n : convertToNestedStructure(originalTool);\n setOriginalJsonString(JSON.stringify(config, null, 2));\n }, [jsonType, originalTool]);\n\n const diff = useMemo(() => {\n if (!originalJsonString || !jsonString || originalJsonString === jsonString) return null;\n return diffLines(originalJsonString, jsonString);\n }, [originalJsonString, jsonString]);\n\n const diffStats = useMemo(() => {\n if (!diff) return null;\n const added = diff.filter((l) => l.type === \"added\").length;\n const removed = diff.filter((l) => l.type === \"removed\").length;\n return { added, removed };\n }, [diff]);\n\n const handleEditToggle = () => {\n setEditValue(jsonString ?? \"\");\n setIsEditing(true);\n };\n\n const handleApply = () => {\n try {\n const parsed = JSON.parse(editValue) as Tool;\n onApply!(parsed);\n setIsEditing(false);\n } catch {\n toast.error(\"Invalid JSON\", { description: \"Please fix the JSON before applying.\" });\n }\n };\n\n const handleCancel = () => {\n setIsEditing(false);\n setEditValue(\"\");\n };\n\n return (\n \n \n \n
\n Output type:\n \n \n \n {jsonOptions.find((option) => option.value === jsonType)?.label}\n \n \n \n \n \n \n \n {jsonOptions.map((option) => (\n {\n setJsonType(currentValue as \"nested\" | \"flat\");\n setOpen(false);\n }}\n >\n {option.label}\n \n \n ))}\n \n \n \n \n \n
\n
\n
\n {onApply && !isEditing && (\n \n \n \n )}\n {onApply && isEditing && (\n \n \n \n )}\n {\n navigator.clipboard.writeText(jsonString!);\n toast(\"Copied!\");\n }}\n >\n \n \n
\n
\n \n {diffStats && !isEditing && (\n
\n {diffStats.added > 0 && (\n \n +{diffStats.added} added\n \n )}\n {diffStats.removed > 0 && (\n \n -{diffStats.removed} removed\n \n )}\n setShowDiff((v) => !v)}\n >\n {showDiff ? \"Full view\" : \"Diff view\"}\n \n
\n )}\n {isEditing ? (\n
\n \n setEditValue(e.target.value)}\n spellCheck={false}\n />\n \n \n \n
\n \n Cancel\n \n \n Apply\n \n
\n
\n ) : diff && showDiff ? (\n \n
\n              {diff.map((line, idx) => (\n                \n                  \n                    {line.type === \"added\" ? \"+ \" : line.type === \"removed\" ? \"- \" : \"  \"}\n                  \n                  {line.text}\n                \n              ))}\n            
\n \n \n \n ) : (\n \n
\n              {jsonString}\n            
\n \n \n \n )}\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/commandly/json-output.tsx" }, @@ -31,7 +31,7 @@ }, { "path": "registry/commandly/utils/flat.ts", - "content": "import type { Command, Parameter, Tool } from \"@/components/commandly/types/flat\";\n\nexport const slugify = (text: string): string => {\n return text\n .toString()\n .toLowerCase()\n .trim()\n .replace(/\\s+/g, \"-\") // Replace spaces with -\n .replace(/[^\\w-]+/g, \"\") // Remove all non-word chars\n .replace(/--+/g, \"-\") // Replace multiple - with single -\n .replace(/^-+/, \"\") // Trim - from start of text\n .replace(/-+$/, \"\"); // Trim - from end of text\n};\n\nexport const getCommandPath = (command: Command, tool: Tool): string => {\n const allCommands = tool.commands;\n const findCommandPath = (\n targetKey: string,\n commands: Command[],\n path: string[] = [],\n ): string[] | null => {\n for (const cmd of commands) {\n if (cmd.name === targetKey) {\n return [...path, cmd.name];\n }\n\n const childCommands = allCommands.filter((c) => c.parentCommandKey === cmd.key);\n if (childCommands.length > 0) {\n const subPath = findCommandPath(targetKey, childCommands, [...path, cmd.name]);\n if (subPath) {\n return subPath;\n }\n }\n }\n return null;\n };\n\n const rootCommands = tool.commands.filter((c) => !c.parentCommandKey);\n const path = findCommandPath(command.name, rootCommands);\n\n if (!path) return command.name;\n\n return path.join(\" \");\n};\n\nexport const getAllSubcommands = (commandKey: string, commands: Command[]): Command[] => {\n const result: Command[] = [];\n\n const findSubcommands = (parentKey: string) => {\n commands.forEach((cmd) => {\n if (cmd.parentCommandKey === parentKey) {\n result.push(cmd);\n findSubcommands(cmd.key);\n }\n });\n };\n\n findSubcommands(commandKey);\n return result;\n};\n\nconst SCHEMA_URL = \"https://commandly.divyeshio.in/specification/flat.json\";\n\nexport const sanitizeToolJSON = (tool: Tool) => {\n const parameters = tool.parameters.map(({ metadata: _metadata, ...param }) => param);\n\n return {\n $schema: SCHEMA_URL,\n ...tool,\n parameters,\n };\n};\n\nexport const exportToStructuredJSON = (tool: Tool) => {\n return {\n $schema: SCHEMA_URL,\n name: tool.binaryName,\n displayName: tool.displayName,\n info: tool.info,\n commands: tool.commands.map((cmd) => ({ ...cmd })),\n parameters: tool.parameters.map(({ metadata: _metadata, ...param }) => param),\n exclusionGroups: tool.exclusionGroups,\n metadata: tool.metadata,\n };\n};\n\nexport const createNewParameter = (isGlobal: boolean, commandKey?: string): Parameter => {\n return {\n key: \"\",\n name: \"\",\n commandKey: isGlobal ? undefined : commandKey,\n parameterType: \"Option\",\n dataType: \"String\",\n ...(isGlobal ? { isGlobal: true } : {}),\n longFlag: \"\",\n };\n};\n\nconst isEmpty = (value: object | null | undefined): boolean => {\n if (value == null) return true;\n if (Array.isArray(value)) return value.length === 0;\n return Object.keys(value).length === 0;\n};\n\nconst cleanParameter = (param: Parameter): Parameter => {\n const cleaned = { ...param };\n\n if (!cleaned.enum || cleaned.enum.values.length === 0) delete cleaned.enum;\n if (isEmpty(cleaned.validations)) delete cleaned.validations;\n if (isEmpty(cleaned.dependencies)) delete cleaned.dependencies;\n\n if (cleaned.metadata) {\n const meta = { ...cleaned.metadata };\n if (isEmpty(meta.tags)) delete meta.tags;\n if (isEmpty(meta)) {\n delete cleaned.metadata;\n } else {\n cleaned.metadata = meta;\n }\n }\n\n return cleaned;\n};\n\nexport const cleanupTool = (tool: Tool): Tool => {\n const cleaned = { ...tool };\n\n if (isEmpty(cleaned.exclusionGroups)) delete cleaned.exclusionGroups;\n if (isEmpty(cleaned.metadata)) delete cleaned.metadata;\n\n cleaned.parameters = cleaned.parameters.map(cleanParameter);\n\n return cleaned;\n};\n", + "content": "import { SCHEMA_URL } from \"@/components/ai-chat/tool-rules\";\nimport type {\n Command,\n ExclusionGroup,\n Parameter,\n ParameterMetadata,\n ParameterValue,\n Tool,\n} from \"@/components/commandly/types/flat\";\n\nexport const slugify = (text: string): string => {\n return text\n .toString()\n .toLowerCase()\n .trim()\n .replace(/\\s+/g, \"-\") // Replace spaces with -\n .replace(/[^\\w-]+/g, \"\") // Remove all non-word chars\n .replace(/--+/g, \"-\") // Replace multiple - with single -\n .replace(/^-+/, \"\") // Trim - from start of text\n .replace(/-+$/, \"\"); // Trim - from end of text\n};\n\nexport const getCommandPath = (command: Command, tool: Tool): string => {\n const allCommands = tool.commands;\n const findCommandPath = (\n targetKey: string,\n commands: Command[],\n path: string[] = [],\n ): string[] | null => {\n for (const cmd of commands) {\n if (cmd.name === targetKey) {\n return [...path, cmd.name];\n }\n\n const childCommands = allCommands.filter((c) => c.parentCommandKey === cmd.key);\n if (childCommands.length > 0) {\n const subPath = findCommandPath(targetKey, childCommands, [...path, cmd.name]);\n if (subPath) {\n return subPath;\n }\n }\n }\n return null;\n };\n\n const rootCommands = tool.commands.filter((c) => !c.parentCommandKey);\n const path = findCommandPath(command.name, rootCommands);\n\n if (!path) return command.name;\n\n return path.join(\" \");\n};\n\nexport const getAllSubcommands = (commandKey: string, commands: Command[]): Command[] => {\n const result: Command[] = [];\n\n const findSubcommands = (parentKey: string) => {\n commands.forEach((cmd) => {\n if (cmd.parentCommandKey === parentKey) {\n result.push(cmd);\n findSubcommands(cmd.key);\n }\n });\n };\n\n findSubcommands(commandKey);\n return result;\n};\n\nexport interface GenerateCommandOptions {\n selectedCommand?: Command | null;\n useLongFlag?: boolean;\n}\n\nfunction getPreferredFlag(param: Parameter, useLongFlag: boolean): string | undefined {\n return useLongFlag ? param.longFlag || param.shortFlag : param.shortFlag || param.longFlag;\n}\n\nexport function generateCommand(\n tool: Tool,\n parameterValues: Record,\n options: GenerateCommandOptions = {},\n): string {\n const hasCommands = tool.commands.length > 0;\n const selectedCommand =\n options.selectedCommand === undefined ? (tool.commands[0] ?? null) : options.selectedCommand;\n const useLongFlag = options.useLongFlag ?? false;\n\n let command = tool.binaryName;\n\n if (hasCommands && selectedCommand) {\n const commandPath = getCommandPath(selectedCommand, tool);\n if (tool.binaryName !== commandPath) {\n command = `${tool.binaryName} ${commandPath}`;\n }\n }\n\n const parametersWithValues: Array<{\n param: Parameter;\n value: ParameterValue;\n }> = [];\n const globalParameters = tool.parameters?.filter((param) => param.isGlobal) ?? [];\n const rootParameters =\n hasCommands && selectedCommand\n ? []\n : (tool.parameters?.filter((param) => !param.commandKey && !param.isGlobal) ?? []);\n const currentParameters = selectedCommand\n ? (tool.parameters?.filter(\n (param) => param.commandKey === selectedCommand.key && !param.isGlobal,\n ) ?? [])\n : [];\n\n [...globalParameters, ...rootParameters, ...currentParameters].forEach((param) => {\n const value = parameterValues[param.key];\n if (value !== undefined && value !== \"\" && value !== false) {\n parametersWithValues.push({ param, value });\n }\n });\n\n const positionalParams = parametersWithValues\n .filter(({ param }) => param.parameterType === \"Argument\")\n .sort((a, b) => (a.param.position || 0) - (b.param.position || 0));\n\n parametersWithValues.forEach(({ param, value }) => {\n if (param.parameterType === \"Flag\") {\n if (value === true) {\n const flag = getPreferredFlag(param, useLongFlag);\n if (flag) command += ` ${flag}`;\n } else if (param.isRepeatable && typeof value === \"number\" && value > 0) {\n const flag = getPreferredFlag(param, useLongFlag);\n if (flag) command += ` ${flag}`.repeat(value);\n }\n return;\n }\n\n if (param.parameterType === \"Option\") {\n const flag = getPreferredFlag(param, useLongFlag);\n if (!flag) return;\n\n const separator = param.keyValueSeparator ?? \" \";\n if (Array.isArray(value)) {\n const entries = value.filter((entry) => entry !== \"\");\n if (entries.length === 0) return;\n\n if (param.arraySeparator) {\n command += ` ${flag}${separator}${entries.join(param.arraySeparator)}`;\n return;\n }\n\n entries.forEach((entry) => {\n command += ` ${flag}${separator}${entry}`;\n });\n return;\n }\n\n command += ` ${flag}${separator}${value}`;\n }\n });\n\n positionalParams.forEach(({ value }) => {\n if (!Array.isArray(value)) {\n command += ` ${value}`;\n }\n });\n\n return command;\n}\n\nexport const exportToStructuredJSON = (tool: Tool) => {\n return {\n $schema: SCHEMA_URL,\n name: tool.binaryName,\n displayName: tool.displayName,\n info: tool.info,\n commands: tool.commands.map((cmd) => ({ ...cmd })),\n parameters: tool.parameters.map(({ metadata: _metadata, ...param }) => param),\n exclusionGroups: tool.exclusionGroups,\n metadata: tool.metadata,\n };\n};\n\nfunction isEmptyArray(value: unknown): boolean {\n return Array.isArray(value) && value.length === 0;\n}\n\nfunction isEmptyObject(value: unknown): boolean {\n return (\n value != null &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n Object.keys(value).length === 0\n );\n}\n\nfunction cleanParameter(param: Parameter): Parameter {\n const cleaned = { ...param };\n\n if (cleaned.isRequired === false) delete cleaned.isRequired;\n if (cleaned.isRepeatable === false) delete cleaned.isRepeatable;\n if (cleaned.isGlobal === false) delete cleaned.isGlobal;\n if (cleaned.keyValueSeparator === \" \") delete cleaned.keyValueSeparator;\n if (cleaned.arraySeparator === \",\" && cleaned.isRepeatable !== true)\n delete cleaned.arraySeparator;\n\n if (!cleaned.enum || isEmptyArray(cleaned.enum.values)) delete cleaned.enum;\n if (isEmptyArray(cleaned.validations)) delete cleaned.validations;\n if (isEmptyArray(cleaned.dependencies)) delete cleaned.dependencies;\n\n if (cleaned.metadata) {\n const meta = { ...cleaned.metadata } as ParameterMetadata;\n if (isEmptyArray(meta.tags)) delete meta.tags;\n if (isEmptyObject(meta)) {\n delete cleaned.metadata;\n } else {\n cleaned.metadata = meta;\n }\n }\n\n return cleaned;\n}\n\nfunction cleanCommand(cmd: Command): Command {\n const cleaned = { ...cmd };\n if (cleaned.interactive === false) delete cleaned.interactive;\n return cleaned;\n}\n\nfunction cleanExclusionGroup(group: ExclusionGroup): ExclusionGroup {\n return { ...group };\n}\n\nexport interface FixToolOptions {\n addSchema?: boolean;\n removeMetadata?: boolean;\n}\n\nexport function fixTool(tool: Tool, options?: FixToolOptions): Tool {\n const addSchema = options?.addSchema ?? false;\n const removeMetadata = options?.removeMetadata ?? false;\n\n const cleaned: Record = { ...tool };\n\n if (addSchema) {\n cleaned[\"$schema\"] = SCHEMA_URL;\n }\n\n if (cleaned.interactive === false) delete cleaned.interactive;\n if (isEmptyObject(cleaned.metadata) || removeMetadata) delete cleaned.metadata;\n if (isEmptyArray(cleaned.exclusionGroups)) delete cleaned.exclusionGroups;\n\n if (\"description\" in cleaned && cleaned.info == null) {\n cleaned.info = { description: cleaned.description as string };\n delete cleaned.description;\n }\n\n if (\"version\" in cleaned && typeof cleaned.version === \"string\") {\n if (cleaned.info && typeof cleaned.info === \"object\") {\n (cleaned.info as Record).version = cleaned.version;\n } else {\n cleaned.info = { version: cleaned.version as string };\n }\n delete cleaned.version;\n }\n\n if (Array.isArray(cleaned.commands)) {\n cleaned.commands = (cleaned.commands as Command[]).map(cleanCommand);\n }\n\n if (Array.isArray(cleaned.parameters)) {\n cleaned.parameters = (cleaned.parameters as Parameter[]).map((p) => {\n const fixed = cleanParameter(p);\n if (removeMetadata) delete fixed.metadata;\n return fixed;\n });\n }\n\n if (\n Array.isArray(cleaned.exclusionGroups) &&\n (cleaned.exclusionGroups as ExclusionGroup[]).length > 0\n ) {\n cleaned.exclusionGroups = (cleaned.exclusionGroups as ExclusionGroup[]).map(\n cleanExclusionGroup,\n );\n }\n\n return cleaned as unknown as Tool;\n}\n", "type": "registry:file", "target": "components/commandly/utils/flat.ts" }, diff --git a/public/r/ui.json b/public/r/ui.json index 3963c4a..a5aa56d 100644 --- a/public/r/ui.json +++ b/public/r/ui.json @@ -23,13 +23,13 @@ "files": [ { "path": "registry/commandly/generated-command.tsx", - "content": "import { Parameter, ParameterValue, Tool, Command } from \"@/components/commandly/types/flat\";\nimport { getCommandPath } from \"@/components/commandly/utils/flat\";\nimport { Button } from \"@/components/ui/button\";\nimport { TerminalIcon, CopyIcon, SaveIcon } from \"lucide-react\";\nimport { useCallback, useEffect, useState, useMemo } from \"react\";\nimport { toast } from \"sonner\";\n\ninterface GeneratedCommandProps {\n tool: Tool;\n selectedCommand?: Command | null;\n parameterValues: Record;\n onSaveCommand?: (command: string) => void;\n}\n\nexport function GeneratedCommand({\n tool,\n selectedCommand: providedCommand,\n parameterValues,\n onSaveCommand,\n}: GeneratedCommandProps) {\n const selectedCommand = providedCommand === undefined ? tool.commands[0] : providedCommand;\n const hasCommands = tool.commands.length > 0;\n const [generatedCommand, setGeneratedCommand] = useState(\"\");\n\n const globalParameters = useMemo(() => {\n return tool.parameters?.filter((p) => p.isGlobal) || [];\n }, [tool]);\n\n const rootParameters = useMemo(() => {\n if (hasCommands && selectedCommand) return [];\n return tool.parameters?.filter((p) => !p.commandKey && !p.isGlobal) || [];\n }, [tool, hasCommands, selectedCommand]);\n\n const currentParameters = useMemo(() => {\n if (!selectedCommand) return [];\n return tool?.parameters?.filter((p) => p.commandKey === selectedCommand?.key) || [];\n }, [tool, selectedCommand]);\n\n const generateCommand = useCallback(() => {\n let command = tool.binaryName;\n\n if (hasCommands && selectedCommand) {\n const commandPath = getCommandPath(selectedCommand, tool);\n if (tool.binaryName !== commandPath) {\n command = `${tool.binaryName} ${commandPath}`;\n }\n }\n\n const parametersWithValues: Array<{\n param: Parameter;\n value: ParameterValue;\n }> = [];\n\n globalParameters.forEach((param) => {\n const value = parameterValues[param.key];\n if (value !== undefined && value !== \"\" && value !== false) {\n parametersWithValues.push({ param, value });\n }\n });\n\n rootParameters.forEach((param) => {\n const value = parameterValues[param.key];\n if (value !== undefined && value !== \"\" && value !== false) {\n parametersWithValues.push({ param, value });\n }\n });\n\n currentParameters.forEach((param) => {\n const value = parameterValues[param.key];\n if (value !== undefined && value !== \"\" && value !== false && !param.isGlobal) {\n parametersWithValues.push({ param, value });\n }\n });\n\n const positionalParams = parametersWithValues\n .filter(({ param }) => param.parameterType === \"Argument\")\n .sort((a, b) => (a.param.position || 0) - (b.param.position || 0));\n\n parametersWithValues.forEach(({ param, value }) => {\n if (param.parameterType === \"Flag\") {\n if (value === true) {\n const flag = param.shortFlag || param.longFlag;\n if (flag) command += ` ${flag}`;\n } else if (param.isRepeatable && typeof value === \"number\" && value > 0) {\n const flag = param.shortFlag || param.longFlag;\n if (flag) command += ` ${flag}`.repeat(value);\n }\n } else if (param.parameterType === \"Option\") {\n const flag = param.shortFlag || param.longFlag;\n if (flag) {\n const separator = param.keyValueSeparator ?? \" \";\n if (Array.isArray(value)) {\n const entries = value.filter((v) => v !== \"\");\n if (entries.length > 0) {\n if (param.arraySeparator) {\n command += ` ${flag}${separator}${entries.join(param.arraySeparator)}`;\n } else {\n entries.forEach((v) => {\n command += ` ${flag}${separator}${v}`;\n });\n }\n }\n } else {\n command += ` ${flag}${separator}${value}`;\n }\n }\n }\n });\n\n positionalParams.forEach(({ value }) => {\n if (!Array.isArray(value)) {\n command += ` ${value}`;\n }\n });\n\n setGeneratedCommand(command);\n }, [\n tool,\n parameterValues,\n selectedCommand,\n hasCommands,\n globalParameters,\n rootParameters,\n currentParameters,\n ]);\n\n useEffect(() => {\n generateCommand();\n }, [generateCommand]);\n\n const copyCommand = () => {\n navigator.clipboard.writeText(generatedCommand);\n toast(\"Command copied!\");\n };\n\n return (\n
\n {generatedCommand ? (\n
\n
\n
{generatedCommand}
\n
\n
\n \n \n Copy Command\n \n {onSaveCommand && (\n onSaveCommand(generatedCommand)}\n variant=\"outline\"\n className=\"w-full sm:flex-1\"\n >\n \n Save Command\n \n )}\n
\n
\n ) : (\n
\n \n

Configure parameters to generate the command.

\n
\n )}\n
\n );\n}\n", + "content": "import { ParameterValue, Tool, Command } from \"@/components/commandly/types/flat\";\nimport { generateCommand } from \"@/components/commandly/utils/flat\";\nimport { Button } from \"@/components/ui/button\";\nimport { CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\nimport { TerminalIcon, CopyIcon, SaveIcon } from \"lucide-react\";\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport { toast } from \"sonner\";\n\ninterface GeneratedCommandProps {\n tool: Tool;\n selectedCommand?: Command | null;\n parameterValues: Record;\n onSaveCommand?: (command: string) => void;\n useLongFlag?: boolean;\n children?: ReactNode;\n}\n\ninterface GeneratedCommandContextValue {\n generatedCommand: string;\n useLongFlag: boolean;\n setUseLongFlag: (value: boolean) => void;\n supportsFlagPreference: boolean;\n onCopyCommand: () => void;\n onSaveCommand?: (command: string) => void;\n}\n\nconst GeneratedCommandContext = createContext(null);\n\nfunction useGeneratedCommandContext() {\n const context = useContext(GeneratedCommandContext);\n\n if (!context) {\n throw new Error(\"GeneratedCommand compound components must be used within GeneratedCommand.\");\n }\n\n return context;\n}\n\nfunction GeneratedCommandRoot({\n tool,\n selectedCommand: providedCommand,\n parameterValues,\n onSaveCommand,\n useLongFlag = false,\n children,\n}: GeneratedCommandProps) {\n const [prefersLongFlag, setPrefersLongFlag] = useState(useLongFlag);\n const supportsFlagPreference = useMemo(\n () =>\n tool.parameters.some(\n (parameter) =>\n parameter.parameterType !== \"Argument\" &&\n Boolean(parameter.shortFlag) &&\n Boolean(parameter.longFlag),\n ),\n [tool.parameters],\n );\n\n useEffect(() => {\n setPrefersLongFlag(useLongFlag);\n }, [useLongFlag]);\n\n const generatedCommand = useMemo(\n () =>\n generateCommand(tool, parameterValues, {\n selectedCommand: providedCommand,\n useLongFlag: prefersLongFlag,\n }),\n [tool, parameterValues, providedCommand, prefersLongFlag],\n );\n\n const copyCommand = useCallback(() => {\n navigator.clipboard.writeText(generatedCommand);\n toast(\"Command copied!\");\n }, [generatedCommand]);\n\n const contextValue = useMemo(\n () => ({\n generatedCommand,\n useLongFlag: prefersLongFlag,\n setUseLongFlag: setPrefersLongFlag,\n supportsFlagPreference,\n onCopyCommand: copyCommand,\n onSaveCommand,\n }),\n [generatedCommand, prefersLongFlag, supportsFlagPreference, copyCommand, onSaveCommand],\n );\n\n return (\n \n {generatedCommand ? (\n (children ?? (\n
\n \n \n
\n ))\n ) : (\n \n )}\n
\n );\n}\n\nfunction GeneratedCommandToolbar({ className, ...props }: ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction GeneratedCommandHeader({\n children,\n className,\n ...props\n}: ComponentProps) {\n return (\n \n \n \n Generated Command\n \n {children}\n \n );\n}\n\nfunction GeneratedCommandFlagPreference({\n className,\n ...props\n}: Omit, \"checked\" | \"onCheckedChange\">) {\n const { supportsFlagPreference, useLongFlag, setUseLongFlag } = useGeneratedCommandContext();\n\n if (!supportsFlagPreference) return null;\n\n return (\n \n );\n}\n\nfunction GeneratedCommandOutput({ className, ...props }: ComponentProps<\"div\">) {\n const { generatedCommand } = useGeneratedCommandContext();\n\n return (\n \n
{generatedCommand}
\n \n );\n}\n\nfunction GeneratedCommandActions({ className, ...props }: ComponentProps<\"div\">) {\n const { generatedCommand, onCopyCommand, onSaveCommand } = useGeneratedCommandContext();\n\n return (\n \n \n \n Copy Command\n \n {onSaveCommand && (\n onSaveCommand(generatedCommand)}\n variant=\"outline\"\n className=\"w-full sm:flex-1\"\n >\n \n Save Command\n \n )}\n \n );\n}\n\nfunction GeneratedCommandEmptyState() {\n return (\n
\n \n

Configure parameters to generate the command.

\n
\n );\n}\n\nexport const GeneratedCommand = Object.assign(GeneratedCommandRoot, {\n Header: GeneratedCommandHeader,\n Toolbar: GeneratedCommandToolbar,\n FlagPreference: GeneratedCommandFlagPreference,\n Output: GeneratedCommandOutput,\n Actions: GeneratedCommandActions,\n EmptyState: GeneratedCommandEmptyState,\n});\n", "type": "registry:component", "target": "components/commandly/generated-command.tsx" }, { "path": "registry/commandly/json-output.tsx", - "content": "import { Tool } from \"@/components/commandly/types/flat\";\nimport { exportToStructuredJSON } from \"@/components/commandly/utils/flat\";\nimport { convertToNestedStructure } from \"@/components/commandly/utils/nested\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardAction, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport {\n Command as UICommand,\n CommandGroup,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport { CheckIcon, ChevronsUpDownIcon, CopyIcon, Edit2Icon, XIcon } from \"lucide-react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nconst jsonOptions = [\n { value: \"nested\", label: \"Nested\" },\n { value: \"flat\", label: \"Flat\" },\n];\n\ntype DiffLine = { type: \"same\" | \"added\" | \"removed\"; text: string };\n\nfunction diffLines(before: string, after: string): DiffLine[] {\n const a = before.split(\"\\n\");\n const b = after.split(\"\\n\");\n const m = a.length;\n const n = b.length;\n const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));\n for (let i = 1; i <= m; i++)\n for (let j = 1; j <= n; j++)\n dp[i][j] =\n a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);\n const result: DiffLine[] = [];\n let i = m,\n j = n;\n while (i > 0 || j > 0) {\n if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {\n result.unshift({ type: \"same\", text: a[i - 1] });\n i--;\n j--;\n } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {\n result.unshift({ type: \"added\", text: b[j - 1] });\n j--;\n } else {\n result.unshift({ type: \"removed\", text: a[i - 1] });\n i--;\n }\n }\n return result;\n}\n\ninterface JsonTypeComponentProps {\n tool: Tool;\n originalTool?: Tool;\n onApply?: (tool: Tool) => void;\n}\n\nexport function JsonOutput({ tool, originalTool, onApply }: JsonTypeComponentProps) {\n const [open, setOpen] = useState(false);\n const [jsonString, setJsonString] = useState();\n const [originalJsonString, setOriginalJsonString] = useState();\n const [jsonType, setJsonType] = useState<\"nested\" | \"flat\">(\"flat\");\n const [isEditing, setIsEditing] = useState(false);\n const [editValue, setEditValue] = useState(\"\");\n const [showDiff, setShowDiff] = useState(true);\n\n useEffect(() => {\n const config =\n jsonType === \"flat\" ? exportToStructuredJSON(tool) : convertToNestedStructure(tool);\n setJsonString(JSON.stringify(config, null, 2));\n }, [jsonType, tool]);\n\n useEffect(() => {\n if (!originalTool) {\n setOriginalJsonString(undefined);\n return;\n }\n const config =\n jsonType === \"flat\"\n ? exportToStructuredJSON(originalTool)\n : convertToNestedStructure(originalTool);\n setOriginalJsonString(JSON.stringify(config, null, 2));\n }, [jsonType, originalTool]);\n\n const diff = useMemo(() => {\n if (!originalJsonString || !jsonString || originalJsonString === jsonString) return null;\n return diffLines(originalJsonString, jsonString);\n }, [originalJsonString, jsonString]);\n\n const diffStats = useMemo(() => {\n if (!diff) return null;\n const added = diff.filter((l) => l.type === \"added\").length;\n const removed = diff.filter((l) => l.type === \"removed\").length;\n return { added, removed };\n }, [diff]);\n\n const handleEditToggle = () => {\n setEditValue(jsonString ?? \"\");\n setIsEditing(true);\n };\n\n const handleApply = () => {\n try {\n const parsed = JSON.parse(editValue) as Tool;\n onApply!(parsed);\n setIsEditing(false);\n } catch {\n toast.error(\"Invalid JSON\", { description: \"Please fix the JSON before applying.\" });\n }\n };\n\n const handleCancel = () => {\n setIsEditing(false);\n setEditValue(\"\");\n };\n\n return (\n \n \n \n
\n Output type:\n \n \n \n {jsonOptions.find((option) => option.value === jsonType)?.label}\n \n \n \n \n \n \n \n {jsonOptions.map((option) => (\n {\n setJsonType(currentValue as \"nested\" | \"flat\");\n setOpen(false);\n }}\n >\n {option.label}\n \n \n ))}\n \n \n \n \n \n
\n
\n
\n {onApply && !isEditing && (\n \n \n \n )}\n {onApply && isEditing && (\n \n \n \n )}\n {\n navigator.clipboard.writeText(jsonString!);\n toast(\"Copied!\");\n }}\n >\n \n \n
\n
\n \n {diffStats && !isEditing && (\n
\n {diffStats.added > 0 && (\n \n +{diffStats.added} added\n \n )}\n {diffStats.removed > 0 && (\n \n -{diffStats.removed} removed\n \n )}\n setShowDiff((v) => !v)}\n >\n {showDiff ? \"Full view\" : \"Diff view\"}\n \n
\n )}\n {isEditing ? (\n
\n \n setEditValue(e.target.value)}\n spellCheck={false}\n />\n \n \n \n
\n \n Cancel\n \n \n Apply\n \n
\n
\n ) : diff && showDiff ? (\n \n
\n              {diff.map((line, idx) => (\n                \n                  \n                    {line.type === \"added\" ? \"+ \" : line.type === \"removed\" ? \"- \" : \"  \"}\n                  \n                  {line.text}\n                \n              ))}\n            
\n \n \n \n ) : (\n \n
\n              {jsonString}\n            
\n \n \n \n )}\n
\n
\n );\n}\n", + "content": "import { Tool } from \"@/components/commandly/types/flat\";\nimport { exportToStructuredJSON } from \"@/components/commandly/utils/flat\";\nimport { convertToNestedStructure } from \"@/components/commandly/utils/nested\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardAction, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport {\n Command as UICommand,\n CommandGroup,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport { CheckIcon, ChevronsUpDownIcon, CopyIcon, Edit2Icon, XIcon } from \"lucide-react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nconst jsonOptions = [\n { value: \"nested\", label: \"Nested\" },\n { value: \"flat\", label: \"Flat\" },\n];\n\ntype DiffLine = { type: \"same\" | \"added\" | \"removed\"; text: string };\n\nfunction diffLines(before: string, after: string): DiffLine[] {\n const a = before.split(\"\\n\");\n const b = after.split(\"\\n\");\n const m = a.length;\n const n = b.length;\n const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));\n for (let i = 1; i <= m; i++)\n for (let j = 1; j <= n; j++)\n dp[i][j] =\n a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);\n const result: DiffLine[] = [];\n let i = m,\n j = n;\n while (i > 0 || j > 0) {\n if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {\n result.unshift({ type: \"same\", text: a[i - 1] });\n i--;\n j--;\n } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {\n result.unshift({ type: \"added\", text: b[j - 1] });\n j--;\n } else {\n result.unshift({ type: \"removed\", text: a[i - 1] });\n i--;\n }\n }\n return result;\n}\n\ninterface JsonTypeComponentProps {\n tool: Tool;\n originalTool?: Tool;\n onApply?: (tool: Tool) => void;\n}\n\nexport function JsonOutput({ tool, originalTool, onApply }: JsonTypeComponentProps) {\n const [open, setOpen] = useState(false);\n const [jsonString, setJsonString] = useState();\n const [originalJsonString, setOriginalJsonString] = useState();\n const [jsonType, setJsonType] = useState<\"nested\" | \"flat\">(\"flat\");\n const [isEditing, setIsEditing] = useState(false);\n const [editValue, setEditValue] = useState(\"\");\n const [showDiff, setShowDiff] = useState(true);\n\n useEffect(() => {\n const config =\n jsonType === \"flat\" ? exportToStructuredJSON(tool) : convertToNestedStructure(tool);\n setJsonString(JSON.stringify(config, null, 2));\n }, [jsonType, tool]);\n\n useEffect(() => {\n if (!originalTool) {\n setOriginalJsonString(undefined);\n return;\n }\n const config =\n jsonType === \"flat\"\n ? exportToStructuredJSON(originalTool)\n : convertToNestedStructure(originalTool);\n setOriginalJsonString(JSON.stringify(config, null, 2));\n }, [jsonType, originalTool]);\n\n const diff = useMemo(() => {\n if (!originalJsonString || !jsonString || originalJsonString === jsonString) return null;\n return diffLines(originalJsonString, jsonString);\n }, [originalJsonString, jsonString]);\n\n const diffStats = useMemo(() => {\n if (!diff) return null;\n const added = diff.filter((l) => l.type === \"added\").length;\n const removed = diff.filter((l) => l.type === \"removed\").length;\n return { added, removed };\n }, [diff]);\n\n const handleEditToggle = () => {\n setEditValue(jsonString ?? \"\");\n setIsEditing(true);\n };\n\n const handleApply = () => {\n try {\n const parsed = JSON.parse(editValue) as Tool;\n onApply!(parsed);\n setIsEditing(false);\n } catch {\n toast.error(\"Invalid JSON\", { description: \"Please fix the JSON before applying.\" });\n }\n };\n\n const handleCancel = () => {\n setIsEditing(false);\n setEditValue(\"\");\n };\n\n return (\n \n \n \n
\n Output type:\n \n \n \n {jsonOptions.find((option) => option.value === jsonType)?.label}\n \n \n \n \n \n \n \n {jsonOptions.map((option) => (\n {\n setJsonType(currentValue as \"nested\" | \"flat\");\n setOpen(false);\n }}\n >\n {option.label}\n \n \n ))}\n \n \n \n \n \n
\n
\n
\n {onApply && !isEditing && (\n \n \n \n )}\n {onApply && isEditing && (\n \n \n \n )}\n {\n navigator.clipboard.writeText(jsonString!);\n toast(\"Copied!\");\n }}\n >\n \n \n
\n
\n \n {diffStats && !isEditing && (\n
\n {diffStats.added > 0 && (\n \n +{diffStats.added} added\n \n )}\n {diffStats.removed > 0 && (\n \n -{diffStats.removed} removed\n \n )}\n setShowDiff((v) => !v)}\n >\n {showDiff ? \"Full view\" : \"Diff view\"}\n \n
\n )}\n {isEditing ? (\n
\n \n setEditValue(e.target.value)}\n spellCheck={false}\n />\n \n \n \n
\n \n Cancel\n \n \n Apply\n \n
\n
\n ) : diff && showDiff ? (\n \n
\n              {diff.map((line, idx) => (\n                \n                  \n                    {line.type === \"added\" ? \"+ \" : line.type === \"removed\" ? \"- \" : \"  \"}\n                  \n                  {line.text}\n                \n              ))}\n            
\n \n \n \n ) : (\n \n
\n              {jsonString}\n            
\n \n \n \n )}\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/commandly/json-output.tsx" }, @@ -59,7 +59,7 @@ }, { "path": "registry/commandly/utils/flat.ts", - "content": "import type { Command, Parameter, Tool } from \"@/components/commandly/types/flat\";\n\nexport const slugify = (text: string): string => {\n return text\n .toString()\n .toLowerCase()\n .trim()\n .replace(/\\s+/g, \"-\") // Replace spaces with -\n .replace(/[^\\w-]+/g, \"\") // Remove all non-word chars\n .replace(/--+/g, \"-\") // Replace multiple - with single -\n .replace(/^-+/, \"\") // Trim - from start of text\n .replace(/-+$/, \"\"); // Trim - from end of text\n};\n\nexport const getCommandPath = (command: Command, tool: Tool): string => {\n const allCommands = tool.commands;\n const findCommandPath = (\n targetKey: string,\n commands: Command[],\n path: string[] = [],\n ): string[] | null => {\n for (const cmd of commands) {\n if (cmd.name === targetKey) {\n return [...path, cmd.name];\n }\n\n const childCommands = allCommands.filter((c) => c.parentCommandKey === cmd.key);\n if (childCommands.length > 0) {\n const subPath = findCommandPath(targetKey, childCommands, [...path, cmd.name]);\n if (subPath) {\n return subPath;\n }\n }\n }\n return null;\n };\n\n const rootCommands = tool.commands.filter((c) => !c.parentCommandKey);\n const path = findCommandPath(command.name, rootCommands);\n\n if (!path) return command.name;\n\n return path.join(\" \");\n};\n\nexport const getAllSubcommands = (commandKey: string, commands: Command[]): Command[] => {\n const result: Command[] = [];\n\n const findSubcommands = (parentKey: string) => {\n commands.forEach((cmd) => {\n if (cmd.parentCommandKey === parentKey) {\n result.push(cmd);\n findSubcommands(cmd.key);\n }\n });\n };\n\n findSubcommands(commandKey);\n return result;\n};\n\nconst SCHEMA_URL = \"https://commandly.divyeshio.in/specification/flat.json\";\n\nexport const sanitizeToolJSON = (tool: Tool) => {\n const parameters = tool.parameters.map(({ metadata: _metadata, ...param }) => param);\n\n return {\n $schema: SCHEMA_URL,\n ...tool,\n parameters,\n };\n};\n\nexport const exportToStructuredJSON = (tool: Tool) => {\n return {\n $schema: SCHEMA_URL,\n name: tool.binaryName,\n displayName: tool.displayName,\n info: tool.info,\n commands: tool.commands.map((cmd) => ({ ...cmd })),\n parameters: tool.parameters.map(({ metadata: _metadata, ...param }) => param),\n exclusionGroups: tool.exclusionGroups,\n metadata: tool.metadata,\n };\n};\n\nexport const createNewParameter = (isGlobal: boolean, commandKey?: string): Parameter => {\n return {\n key: \"\",\n name: \"\",\n commandKey: isGlobal ? undefined : commandKey,\n parameterType: \"Option\",\n dataType: \"String\",\n ...(isGlobal ? { isGlobal: true } : {}),\n longFlag: \"\",\n };\n};\n\nconst isEmpty = (value: object | null | undefined): boolean => {\n if (value == null) return true;\n if (Array.isArray(value)) return value.length === 0;\n return Object.keys(value).length === 0;\n};\n\nconst cleanParameter = (param: Parameter): Parameter => {\n const cleaned = { ...param };\n\n if (!cleaned.enum || cleaned.enum.values.length === 0) delete cleaned.enum;\n if (isEmpty(cleaned.validations)) delete cleaned.validations;\n if (isEmpty(cleaned.dependencies)) delete cleaned.dependencies;\n\n if (cleaned.metadata) {\n const meta = { ...cleaned.metadata };\n if (isEmpty(meta.tags)) delete meta.tags;\n if (isEmpty(meta)) {\n delete cleaned.metadata;\n } else {\n cleaned.metadata = meta;\n }\n }\n\n return cleaned;\n};\n\nexport const cleanupTool = (tool: Tool): Tool => {\n const cleaned = { ...tool };\n\n if (isEmpty(cleaned.exclusionGroups)) delete cleaned.exclusionGroups;\n if (isEmpty(cleaned.metadata)) delete cleaned.metadata;\n\n cleaned.parameters = cleaned.parameters.map(cleanParameter);\n\n return cleaned;\n};\n", + "content": "import { SCHEMA_URL } from \"@/components/ai-chat/tool-rules\";\nimport type {\n Command,\n ExclusionGroup,\n Parameter,\n ParameterMetadata,\n ParameterValue,\n Tool,\n} from \"@/components/commandly/types/flat\";\n\nexport const slugify = (text: string): string => {\n return text\n .toString()\n .toLowerCase()\n .trim()\n .replace(/\\s+/g, \"-\") // Replace spaces with -\n .replace(/[^\\w-]+/g, \"\") // Remove all non-word chars\n .replace(/--+/g, \"-\") // Replace multiple - with single -\n .replace(/^-+/, \"\") // Trim - from start of text\n .replace(/-+$/, \"\"); // Trim - from end of text\n};\n\nexport const getCommandPath = (command: Command, tool: Tool): string => {\n const allCommands = tool.commands;\n const findCommandPath = (\n targetKey: string,\n commands: Command[],\n path: string[] = [],\n ): string[] | null => {\n for (const cmd of commands) {\n if (cmd.name === targetKey) {\n return [...path, cmd.name];\n }\n\n const childCommands = allCommands.filter((c) => c.parentCommandKey === cmd.key);\n if (childCommands.length > 0) {\n const subPath = findCommandPath(targetKey, childCommands, [...path, cmd.name]);\n if (subPath) {\n return subPath;\n }\n }\n }\n return null;\n };\n\n const rootCommands = tool.commands.filter((c) => !c.parentCommandKey);\n const path = findCommandPath(command.name, rootCommands);\n\n if (!path) return command.name;\n\n return path.join(\" \");\n};\n\nexport const getAllSubcommands = (commandKey: string, commands: Command[]): Command[] => {\n const result: Command[] = [];\n\n const findSubcommands = (parentKey: string) => {\n commands.forEach((cmd) => {\n if (cmd.parentCommandKey === parentKey) {\n result.push(cmd);\n findSubcommands(cmd.key);\n }\n });\n };\n\n findSubcommands(commandKey);\n return result;\n};\n\nexport interface GenerateCommandOptions {\n selectedCommand?: Command | null;\n useLongFlag?: boolean;\n}\n\nfunction getPreferredFlag(param: Parameter, useLongFlag: boolean): string | undefined {\n return useLongFlag ? param.longFlag || param.shortFlag : param.shortFlag || param.longFlag;\n}\n\nexport function generateCommand(\n tool: Tool,\n parameterValues: Record,\n options: GenerateCommandOptions = {},\n): string {\n const hasCommands = tool.commands.length > 0;\n const selectedCommand =\n options.selectedCommand === undefined ? (tool.commands[0] ?? null) : options.selectedCommand;\n const useLongFlag = options.useLongFlag ?? false;\n\n let command = tool.binaryName;\n\n if (hasCommands && selectedCommand) {\n const commandPath = getCommandPath(selectedCommand, tool);\n if (tool.binaryName !== commandPath) {\n command = `${tool.binaryName} ${commandPath}`;\n }\n }\n\n const parametersWithValues: Array<{\n param: Parameter;\n value: ParameterValue;\n }> = [];\n const globalParameters = tool.parameters?.filter((param) => param.isGlobal) ?? [];\n const rootParameters =\n hasCommands && selectedCommand\n ? []\n : (tool.parameters?.filter((param) => !param.commandKey && !param.isGlobal) ?? []);\n const currentParameters = selectedCommand\n ? (tool.parameters?.filter(\n (param) => param.commandKey === selectedCommand.key && !param.isGlobal,\n ) ?? [])\n : [];\n\n [...globalParameters, ...rootParameters, ...currentParameters].forEach((param) => {\n const value = parameterValues[param.key];\n if (value !== undefined && value !== \"\" && value !== false) {\n parametersWithValues.push({ param, value });\n }\n });\n\n const positionalParams = parametersWithValues\n .filter(({ param }) => param.parameterType === \"Argument\")\n .sort((a, b) => (a.param.position || 0) - (b.param.position || 0));\n\n parametersWithValues.forEach(({ param, value }) => {\n if (param.parameterType === \"Flag\") {\n if (value === true) {\n const flag = getPreferredFlag(param, useLongFlag);\n if (flag) command += ` ${flag}`;\n } else if (param.isRepeatable && typeof value === \"number\" && value > 0) {\n const flag = getPreferredFlag(param, useLongFlag);\n if (flag) command += ` ${flag}`.repeat(value);\n }\n return;\n }\n\n if (param.parameterType === \"Option\") {\n const flag = getPreferredFlag(param, useLongFlag);\n if (!flag) return;\n\n const separator = param.keyValueSeparator ?? \" \";\n if (Array.isArray(value)) {\n const entries = value.filter((entry) => entry !== \"\");\n if (entries.length === 0) return;\n\n if (param.arraySeparator) {\n command += ` ${flag}${separator}${entries.join(param.arraySeparator)}`;\n return;\n }\n\n entries.forEach((entry) => {\n command += ` ${flag}${separator}${entry}`;\n });\n return;\n }\n\n command += ` ${flag}${separator}${value}`;\n }\n });\n\n positionalParams.forEach(({ value }) => {\n if (!Array.isArray(value)) {\n command += ` ${value}`;\n }\n });\n\n return command;\n}\n\nexport const exportToStructuredJSON = (tool: Tool) => {\n return {\n $schema: SCHEMA_URL,\n name: tool.binaryName,\n displayName: tool.displayName,\n info: tool.info,\n commands: tool.commands.map((cmd) => ({ ...cmd })),\n parameters: tool.parameters.map(({ metadata: _metadata, ...param }) => param),\n exclusionGroups: tool.exclusionGroups,\n metadata: tool.metadata,\n };\n};\n\nfunction isEmptyArray(value: unknown): boolean {\n return Array.isArray(value) && value.length === 0;\n}\n\nfunction isEmptyObject(value: unknown): boolean {\n return (\n value != null &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n Object.keys(value).length === 0\n );\n}\n\nfunction cleanParameter(param: Parameter): Parameter {\n const cleaned = { ...param };\n\n if (cleaned.isRequired === false) delete cleaned.isRequired;\n if (cleaned.isRepeatable === false) delete cleaned.isRepeatable;\n if (cleaned.isGlobal === false) delete cleaned.isGlobal;\n if (cleaned.keyValueSeparator === \" \") delete cleaned.keyValueSeparator;\n if (cleaned.arraySeparator === \",\" && cleaned.isRepeatable !== true)\n delete cleaned.arraySeparator;\n\n if (!cleaned.enum || isEmptyArray(cleaned.enum.values)) delete cleaned.enum;\n if (isEmptyArray(cleaned.validations)) delete cleaned.validations;\n if (isEmptyArray(cleaned.dependencies)) delete cleaned.dependencies;\n\n if (cleaned.metadata) {\n const meta = { ...cleaned.metadata } as ParameterMetadata;\n if (isEmptyArray(meta.tags)) delete meta.tags;\n if (isEmptyObject(meta)) {\n delete cleaned.metadata;\n } else {\n cleaned.metadata = meta;\n }\n }\n\n return cleaned;\n}\n\nfunction cleanCommand(cmd: Command): Command {\n const cleaned = { ...cmd };\n if (cleaned.interactive === false) delete cleaned.interactive;\n return cleaned;\n}\n\nfunction cleanExclusionGroup(group: ExclusionGroup): ExclusionGroup {\n return { ...group };\n}\n\nexport interface FixToolOptions {\n addSchema?: boolean;\n removeMetadata?: boolean;\n}\n\nexport function fixTool(tool: Tool, options?: FixToolOptions): Tool {\n const addSchema = options?.addSchema ?? false;\n const removeMetadata = options?.removeMetadata ?? false;\n\n const cleaned: Record = { ...tool };\n\n if (addSchema) {\n cleaned[\"$schema\"] = SCHEMA_URL;\n }\n\n if (cleaned.interactive === false) delete cleaned.interactive;\n if (isEmptyObject(cleaned.metadata) || removeMetadata) delete cleaned.metadata;\n if (isEmptyArray(cleaned.exclusionGroups)) delete cleaned.exclusionGroups;\n\n if (\"description\" in cleaned && cleaned.info == null) {\n cleaned.info = { description: cleaned.description as string };\n delete cleaned.description;\n }\n\n if (\"version\" in cleaned && typeof cleaned.version === \"string\") {\n if (cleaned.info && typeof cleaned.info === \"object\") {\n (cleaned.info as Record).version = cleaned.version;\n } else {\n cleaned.info = { version: cleaned.version as string };\n }\n delete cleaned.version;\n }\n\n if (Array.isArray(cleaned.commands)) {\n cleaned.commands = (cleaned.commands as Command[]).map(cleanCommand);\n }\n\n if (Array.isArray(cleaned.parameters)) {\n cleaned.parameters = (cleaned.parameters as Parameter[]).map((p) => {\n const fixed = cleanParameter(p);\n if (removeMetadata) delete fixed.metadata;\n return fixed;\n });\n }\n\n if (\n Array.isArray(cleaned.exclusionGroups) &&\n (cleaned.exclusionGroups as ExclusionGroup[]).length > 0\n ) {\n cleaned.exclusionGroups = (cleaned.exclusionGroups as ExclusionGroup[]).map(\n cleanExclusionGroup,\n );\n }\n\n return cleaned as unknown as Tool;\n}\n", "type": "registry:file", "target": "components/commandly/utils/flat.ts" }, diff --git a/registry/commandly/__tests__/generated-command.test.tsx b/registry/commandly/__tests__/generated-command.test.tsx index d46cce4..dd86059 100644 --- a/registry/commandly/__tests__/generated-command.test.tsx +++ b/registry/commandly/__tests__/generated-command.test.tsx @@ -1,5 +1,6 @@ import { GeneratedCommand } from "../generated-command"; -import { render, screen } from "@testing-library/react"; +import { generateCommand } from "@/components/commandly/utils/flat"; +import { fireEvent, render, screen } from "@testing-library/react"; const testTool = { binaryName: "tool", @@ -322,4 +323,262 @@ describe("GeneratedCommand", () => { const output = screen.getByText(/mycli/); expect(output.textContent).toBe("mycli config get app.name"); }); + + it("renders long flags when useLongFlag is enabled", () => { + const tool = { + binaryName: "curl", + displayName: "Curl", + commands: [{ key: "curl", name: "curl", sortOrder: 1 }], + parameters: [ + { + key: "request", + name: "Request", + commandKey: "curl", + parameterType: "Option" as const, + dataType: "String" as const, + shortFlag: "-X", + longFlag: "--request", + sortOrder: 1, + }, + ], + }; + + render( + , + ); + + const output = screen.getByText(/curl/); + expect(output.textContent).toBe("curl --request POST"); + }); + + it("toggles between short and long flags from the UI", () => { + const tool = { + binaryName: "curl", + displayName: "Curl", + commands: [{ key: "curl", name: "curl", sortOrder: 1 }], + parameters: [ + { + key: "request", + name: "Request", + commandKey: "curl", + parameterType: "Option" as const, + dataType: "String" as const, + shortFlag: "-X", + longFlag: "--request", + sortOrder: 1, + }, + ], + }; + + render( + + + + + + + , + ); + + expect(screen.getByText("curl -X POST")).toBeInTheDocument(); + expect(screen.getByText(/generated command/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("switch", { name: /long flags/i })); + + expect(screen.getByText("curl --request POST")).toBeInTheDocument(); + }); + + it("allows composing a custom toolbar without affecting default actions", () => { + const tool = { + binaryName: "curl", + displayName: "Curl", + commands: [{ key: "curl", name: "curl", sortOrder: 1 }], + parameters: [ + { + key: "request", + name: "Request", + commandKey: "curl", + parameterType: "Option" as const, + dataType: "String" as const, + shortFlag: "-X", + longFlag: "--request", + sortOrder: 1, + }, + ], + }; + + render( + + + Custom Controls + + + + + + + , + ); + + expect(screen.getByText("Custom Controls")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /copy command/i })).toBeInTheDocument(); + }); + + it("still allows rendering only the output without header or preference", () => { + const tool = { + binaryName: "curl", + displayName: "Curl", + commands: [{ key: "curl", name: "curl", sortOrder: 1 }], + parameters: [ + { + key: "request", + name: "Request", + commandKey: "curl", + parameterType: "Option" as const, + dataType: "String" as const, + shortFlag: "-X", + longFlag: "--request", + sortOrder: 1, + }, + ], + }; + + render( + + + , + ); + + expect(screen.getByText("curl -X POST")).toBeInTheDocument(); + expect(screen.queryByText(/generated command/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("switch", { name: /long flags/i })).not.toBeInTheDocument(); + }); +}); + +describe("generateCommand", () => { + it("prefers short flags by default", () => { + const command = generateCommand( + { + binaryName: "curl", + displayName: "Curl", + commands: [{ key: "curl", name: "curl", sortOrder: 1 }], + parameters: [ + { + key: "request", + name: "Request", + commandKey: "curl", + parameterType: "Option", + dataType: "String", + shortFlag: "-X", + longFlag: "--request", + sortOrder: 1, + }, + { + key: "verbose", + name: "Verbose", + commandKey: "curl", + parameterType: "Flag", + dataType: "Boolean", + shortFlag: "-v", + longFlag: "--verbose", + sortOrder: 2, + }, + ], + }, + { request: "POST", verbose: true }, + ); + + expect(command).toBe("curl -X POST -v"); + }); + + it("can prefer long flags for flags and options", () => { + const command = generateCommand( + { + binaryName: "curl", + displayName: "Curl", + commands: [{ key: "curl", name: "curl", sortOrder: 1 }], + parameters: [ + { + key: "request", + name: "Request", + commandKey: "curl", + parameterType: "Option", + dataType: "String", + shortFlag: "-X", + longFlag: "--request", + sortOrder: 1, + }, + { + key: "verbose", + name: "Verbose", + commandKey: "curl", + parameterType: "Flag", + dataType: "Boolean", + shortFlag: "-v", + longFlag: "--verbose", + sortOrder: 2, + }, + ], + }, + { request: "POST", verbose: true }, + { useLongFlag: true }, + ); + + expect(command).toBe("curl --request POST --verbose"); + }); + + it("matches root and global parameter handling when no command is selected", () => { + const command = generateCommand( + { + binaryName: "mycli", + displayName: "My CLI", + commands: [{ key: "sub", name: "sub", sortOrder: 0 }], + parameters: [ + { + key: "verbose", + name: "Verbose", + parameterType: "Flag", + dataType: "Boolean", + longFlag: "--verbose", + isGlobal: true, + sortOrder: 1, + }, + { + key: "config", + name: "Config", + parameterType: "Option", + dataType: "String", + longFlag: "--config", + sortOrder: 2, + }, + { + key: "output", + name: "Output", + parameterType: "Option", + dataType: "String", + commandKey: "sub", + longFlag: "--output", + sortOrder: 3, + }, + ], + }, + { verbose: true, config: "app.json", output: "ignored.txt" }, + { selectedCommand: null }, + ); + + expect(command).toBe("mycli --verbose --config app.json"); + }); }); diff --git a/registry/commandly/__tests__/json-output.test.tsx b/registry/commandly/__tests__/json-output.test.tsx index 1e749ca..e8919b7 100644 --- a/registry/commandly/__tests__/json-output.test.tsx +++ b/registry/commandly/__tests__/json-output.test.tsx @@ -1,8 +1,8 @@ +import { defaultTool } from "../../../tests/test-utils"; import { JsonOutput } from "../json-output"; import type { Tool } from "@/components/commandly/types/flat"; import { exportToStructuredJSON } from "@/components/commandly/utils/flat"; import { convertToNestedStructure } from "@/components/commandly/utils/nested"; -import { defaultTool } from "@/lib/utils"; import { render, screen } from "@testing-library/react"; import { OnUrlUpdateFunction, withNuqsTestingAdapter } from "nuqs/adapters/testing"; @@ -32,7 +32,7 @@ describe("JsonOutput", () => { }); expect(screen.getByRole("combobox")).toBeInTheDocument(); - expect(screen.getByText(/\"binaryName\"|\"name\"/)).toBeInTheDocument(); + expect(screen.getByText(/"binaryName"|"name"/)).toBeInTheDocument(); }); }); diff --git a/registry/commandly/__tests__/tool-fixer.test.ts b/registry/commandly/__tests__/tool-fixer.test.ts new file mode 100644 index 0000000..4d71eb1 --- /dev/null +++ b/registry/commandly/__tests__/tool-fixer.test.ts @@ -0,0 +1,229 @@ +import { SCHEMA_URL } from "@/components/ai-chat/tool-rules"; +import type { Tool, Parameter } from "@/components/commandly/types/flat"; +import { fixTool } from "@/components/commandly/utils/flat"; + +function baseTool(overrides?: Partial): Tool { + return { + binaryName: "test-tool", + displayName: "Test Tool", + commands: [], + parameters: [], + ...overrides, + }; +} + +function baseParam(overrides?: Partial): Parameter { + return { + key: "param-1", + name: "Param 1", + parameterType: "Option", + dataType: "String", + ...overrides, + }; +} + +describe("fixTool", () => { + it("removes isRequired: false from parameters", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", isRequired: false })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0]).not.toHaveProperty("isRequired"); + }); + + it("preserves isRequired: true", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", isRequired: true })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0].isRequired).toBe(true); + }); + + it("removes isRepeatable: false from parameters", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", isRepeatable: false })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0]).not.toHaveProperty("isRepeatable"); + }); + + it("removes isGlobal: false from parameters", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", isGlobal: false })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0]).not.toHaveProperty("isGlobal"); + }); + + it("removes default keyValueSeparator from parameters", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", keyValueSeparator: " " })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0]).not.toHaveProperty("keyValueSeparator"); + }); + + it("preserves non-default keyValueSeparator from parameters", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", keyValueSeparator: "=" })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0].keyValueSeparator).toBe("="); + }); + + it("removes default arraySeparator from non-repeatable parameters", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", arraySeparator: "," })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0]).not.toHaveProperty("arraySeparator"); + }); + + it("preserves arraySeparator for repeatable parameters", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", isRepeatable: true, arraySeparator: "," })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0].arraySeparator).toBe(","); + }); + + it("removes empty validations array", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", validations: [] })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0]).not.toHaveProperty("validations"); + }); + + it("removes empty dependencies array", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", dependencies: [] })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0]).not.toHaveProperty("dependencies"); + }); + + it("removes enum with empty values", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", enum: { values: [] } })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0]).not.toHaveProperty("enum"); + }); + + it("preserves enum with values", () => { + const tool = baseTool({ + parameters: [ + baseParam({ + key: "p1", + dataType: "Enum", + enum: { values: [{ value: "a", displayName: "A" }] }, + }), + ], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0].enum?.values).toHaveLength(1); + }); + + it("removes empty exclusionGroups", () => { + const tool = baseTool({ exclusionGroups: [] }); + const fixed = fixTool(tool); + expect(fixed).not.toHaveProperty("exclusionGroups"); + }); + + it("preserves non-empty exclusionGroups", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1" }), baseParam({ key: "p2" })], + exclusionGroups: [ + { name: "Group", exclusionType: "mutual_exclusive", parameterKeys: ["p1", "p2"] }, + ], + }); + const fixed = fixTool(tool); + expect(fixed.exclusionGroups).toHaveLength(1); + }); + + it("removes empty metadata", () => { + const tool = baseTool({ metadata: {} as Tool["metadata"] }); + const fixed = fixTool(tool); + expect(fixed).not.toHaveProperty("metadata"); + }); + + it("removes interactive: false from tool", () => { + const tool = baseTool({ interactive: false }); + const fixed = fixTool(tool); + expect(fixed).not.toHaveProperty("interactive"); + }); + + it("removes interactive: false from commands", () => { + const tool = baseTool({ + commands: [{ key: "run", name: "Run", interactive: false }], + }); + const fixed = fixTool(tool); + expect(fixed.commands[0]).not.toHaveProperty("interactive"); + }); + + it("preserves interactive: true", () => { + const tool = baseTool({ interactive: true }); + const fixed = fixTool(tool); + expect(fixed.interactive).toBe(true); + }); + + it("removes empty parameter metadata", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", metadata: {} })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0]).not.toHaveProperty("metadata"); + }); + + it("removes parameter metadata with empty tags", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", metadata: { tags: [] } })], + }); + const fixed = fixTool(tool); + expect(fixed.parameters[0]).not.toHaveProperty("metadata"); + }); + + it("adds $schema when addSchema is true", () => { + const tool = baseTool(); + const fixed = fixTool(tool, { addSchema: true }); + expect((fixed as unknown as Record)["$schema"]).toBe(SCHEMA_URL); + }); + + it("does not add $schema by default", () => { + const tool = baseTool(); + const fixed = fixTool(tool); + expect((fixed as unknown as Record)["$schema"]).toBeUndefined(); + }); + + it("removes parameter metadata when removeMetadata is true", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", metadata: { tags: ["test"] } })], + }); + const fixed = fixTool(tool, { removeMetadata: true }); + expect(fixed.parameters[0]).not.toHaveProperty("metadata"); + }); + + it("moves top-level description into info object", () => { + const tool = { ...baseTool(), description: "A test tool" } as Tool & { description?: string }; + const fixed = fixTool(tool as Tool); + expect(fixed.info?.description).toBe("A test tool"); + expect((fixed as unknown as Record)["description"]).toBeUndefined(); + }); + + it("moves top-level version into info object", () => { + const tool = { ...baseTool(), version: "1.0.0" } as Tool & { version?: string }; + const fixed = fixTool(tool as Tool); + expect(fixed.info?.version).toBe("1.0.0"); + expect((fixed as unknown as Record)["version"]).toBeUndefined(); + }); + + it("does not mutate the original tool", () => { + const tool = baseTool({ + parameters: [baseParam({ key: "p1", isRequired: false, validations: [] })], + }); + const original = JSON.stringify(tool); + fixTool(tool); + expect(JSON.stringify(tool)).toBe(original); + }); +}); diff --git a/registry/commandly/__tests__/tool-renderer.test.tsx b/registry/commandly/__tests__/tool-renderer.test.tsx index 0d70af5..0e0c731 100644 --- a/registry/commandly/__tests__/tool-renderer.test.tsx +++ b/registry/commandly/__tests__/tool-renderer.test.tsx @@ -1,10 +1,10 @@ +import { defaultTool } from "../../../tests/test-utils"; import { ToolRenderer, defaultComponents } from "../tool-renderer"; import { ParameterRendererEntry } from "@/components/commandly/types/renderer"; -import { createNewParameter } from "@/components/commandly/utils/flat"; -import { defaultTool } from "@/lib/utils"; import { render, screen } from "@testing-library/react"; const baseCommand = { key: "my-tool", name: "my-tool", sortOrder: 0 }; const baseTool = { ...defaultTool(), commands: [baseCommand] }; +const baseParam = { commandKey: "my-tool" }; describe("ToolRenderer", () => { it("renders no parameters message if none", () => { @@ -21,7 +21,7 @@ describe("ToolRenderer", () => { it("renders a Flag parameter as a switch", () => { const param = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "verbose", name: "Verbose", parameterType: "Flag" as const, @@ -41,7 +41,7 @@ describe("ToolRenderer", () => { it("renders an Argument parameter as an input", () => { const param = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "target", name: "Target", parameterType: "Argument" as const, @@ -61,7 +61,7 @@ describe("ToolRenderer", () => { it("renders an Option/Enum parameter as a select", () => { const param = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "format", name: "Format", parameterType: "Option" as const, @@ -82,7 +82,7 @@ describe("ToolRenderer", () => { it("renders an Option/Boolean parameter as a switch", () => { const param = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "enabled", name: "Enabled", parameterType: "Option" as const, @@ -102,7 +102,7 @@ describe("ToolRenderer", () => { it("renders an Option/String parameter as a text input", () => { const param = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "output", name: "Output", parameterType: "Option" as const, @@ -122,7 +122,7 @@ describe("ToolRenderer", () => { it("renders a repeatable Option with an 'Add another' button", () => { const param = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "header", name: "Header", parameterType: "Option" as const, @@ -143,7 +143,7 @@ describe("ToolRenderer", () => { it("renders multiple rows for a repeatable Option with array value", () => { const param = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "header", name: "Header", parameterType: "Option" as const, @@ -166,7 +166,7 @@ describe("ToolRenderer", () => { it("renders an allowMultiple Enum parameter without crashing when value is an array (repeatable-to-non-repeatable transition)", () => { const param = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "format", name: "Format", parameterType: "Option" as const, @@ -196,7 +196,7 @@ describe("ToolRenderer", () => { it("custom catalog entry takes precedence over built-in", () => { const param = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "verbose", name: "Verbose", parameterType: "Flag" as const, @@ -345,7 +345,7 @@ describe("ToolRenderer", () => { it("does not render info icon when description is empty or absent", () => { const paramNoDesc = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "flag-no-desc", name: "NoDesc", parameterType: "Flag" as const, @@ -353,7 +353,7 @@ describe("ToolRenderer", () => { description: undefined, }; const paramEmptyDesc = { - ...createNewParameter(false, "my-tool"), + ...baseParam, key: "flag-empty-desc", name: "EmptyDesc", parameterType: "Flag" as const, diff --git a/registry/commandly/__tests__/tool-validation.test.ts b/registry/commandly/__tests__/tool-validation.test.ts new file mode 100644 index 0000000..64c58fc --- /dev/null +++ b/registry/commandly/__tests__/tool-validation.test.ts @@ -0,0 +1,297 @@ +import type { Tool, Parameter } from "@/components/commandly/types/flat"; +import { + validateTool, + formatValidationErrors, + hasErrors, +} from "@/components/commandly/utils/tool-validation"; + +function baseTool(overrides?: Partial): Tool { + return { + binaryName: "test-tool", + displayName: "Test Tool", + commands: [], + parameters: [], + ...overrides, + }; +} + +function baseParam(overrides?: Partial): Parameter { + return { + key: "param-1", + name: "Param 1", + parameterType: "Option", + dataType: "String", + ...overrides, + }; +} + +describe("validateTool", () => { + it("returns no errors for a valid minimal tool", () => { + const errors = validateTool(baseTool()); + expect(errors).toHaveLength(0); + }); + + it("reports missing binaryName", () => { + const errors = validateTool(baseTool({ binaryName: "" })); + expect(errors).toContainEqual( + expect.objectContaining({ path: "binaryName", severity: "error" }), + ); + }); + + it("reports missing displayName", () => { + const errors = validateTool(baseTool({ displayName: "" })); + expect(errors).toContainEqual( + expect.objectContaining({ path: "displayName", severity: "error" }), + ); + }); + + it("reports duplicate parameter keys", () => { + const errors = validateTool( + baseTool({ + parameters: [baseParam({ key: "dup" }), baseParam({ key: "dup" })], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ message: expect.stringContaining("Duplicate parameter key") }), + ); + }); + + it("reports duplicate command keys", () => { + const errors = validateTool( + baseTool({ + commands: [ + { key: "cmd", name: "Cmd" }, + { key: "cmd", name: "Cmd 2" }, + ], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ message: expect.stringContaining("Duplicate command key") }), + ); + }); + + it("reports parameter missing commandKey when commands exist", () => { + const errors = validateTool( + baseTool({ + commands: [{ key: "run", name: "Run" }], + parameters: [baseParam({ key: "p1" })], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining("must have commandKey or isGlobal"), + }), + ); + }); + + it("reports parameter with commandKey when no commands exist", () => { + const errors = validateTool( + baseTool({ + commands: [], + parameters: [baseParam({ key: "p1", commandKey: "run" })], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining("must not have commandKey or isGlobal"), + }), + ); + }); + + it("reports parameter with both isGlobal and commandKey", () => { + const errors = validateTool( + baseTool({ + commands: [{ key: "run", name: "Run" }], + parameters: [baseParam({ key: "p1", isGlobal: true, commandKey: "run" })], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining("both isGlobal and commandKey"), + }), + ); + }); + + it("reports parameter referencing non-existent command", () => { + const errors = validateTool( + baseTool({ + commands: [{ key: "run", name: "Run" }], + parameters: [baseParam({ key: "p1", commandKey: "missing" })], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('non-existent command "missing"'), + }), + ); + }); + + it("warns when flag has non-Boolean dataType", () => { + const errors = validateTool( + baseTool({ + parameters: [baseParam({ key: "f1", parameterType: "Flag", dataType: "String" })], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + severity: "warning", + message: expect.stringContaining('should have dataType "Boolean"'), + }), + ); + }); + + it("reports enum parameter with no enum values", () => { + const errors = validateTool( + baseTool({ + parameters: [baseParam({ key: "e1", dataType: "Enum" })], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining("no enum values"), + }), + ); + }); + + it("reports enum value missing displayName", () => { + const errors = validateTool( + baseTool({ + parameters: [ + baseParam({ + key: "e1", + dataType: "Enum", + enum: { + values: [{ value: "a", displayName: "" }], + }, + }), + ], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining("no displayName"), + }), + ); + }); + + it("reports dependency referencing non-existent parameter", () => { + const errors = validateTool( + baseTool({ + parameters: [ + baseParam({ + key: "p1", + dependencies: [ + { + key: "d1", + parameterKey: "p1", + dependsOnParameterKey: "missing", + dependencyType: "requires", + }, + ], + }), + ], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('non-existent parameter "missing"'), + }), + ); + }); + + it("reports exclusion group referencing non-existent parameter", () => { + const errors = validateTool( + baseTool({ + parameters: [baseParam({ key: "p1" }), baseParam({ key: "p2" })], + exclusionGroups: [ + { + name: "Group", + exclusionType: "mutual_exclusive", + parameterKeys: ["p1", "missing"], + }, + ], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('non-existent parameter "missing"'), + }), + ); + }); + + it("reports exclusion group with fewer than 2 parameters", () => { + const errors = validateTool( + baseTool({ + parameters: [baseParam({ key: "p1" })], + exclusionGroups: [ + { + name: "Group", + exclusionType: "mutual_exclusive", + parameterKeys: ["p1"], + }, + ], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining("at least 2 parameters"), + }), + ); + }); + + it("reports command referencing non-existent parent", () => { + const errors = validateTool( + baseTool({ + commands: [{ key: "sub", name: "Sub", parentCommandKey: "missing" }], + }), + ); + expect(errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('non-existent parent command "missing"'), + }), + ); + }); + + it("accepts valid tool with commands and global/command parameters", () => { + const errors = validateTool( + baseTool({ + commands: [{ key: "run", name: "Run" }], + parameters: [ + baseParam({ key: "verbose", isGlobal: true, parameterType: "Flag", dataType: "Boolean" }), + baseParam({ key: "target", commandKey: "run" }), + ], + }), + ); + const realErrors = errors.filter((e) => e.severity === "error"); + expect(realErrors).toHaveLength(0); + }); +}); + +describe("formatValidationErrors", () => { + it("returns empty string for no errors", () => { + expect(formatValidationErrors([])).toBe(""); + }); + + it("formats errors with severity icons", () => { + const result = formatValidationErrors([ + { path: "test", message: "broken", severity: "error" }, + { path: "test2", message: "warning", severity: "warning" }, + ]); + expect(result).toContain("❌"); + expect(result).toContain("⚠️"); + }); +}); + +describe("hasErrors", () => { + it("returns false for empty array", () => { + expect(hasErrors([])).toBe(false); + }); + + it("returns false for warnings only", () => { + expect(hasErrors([{ path: "x", message: "warn", severity: "warning" }])).toBe(false); + }); + + it("returns true when errors present", () => { + expect(hasErrors([{ path: "x", message: "err", severity: "error" }])).toBe(true); + }); +}); diff --git a/registry/commandly/generated-command.tsx b/registry/commandly/generated-command.tsx index 05ec904..fe09ea1 100644 --- a/registry/commandly/generated-command.tsx +++ b/registry/commandly/generated-command.tsx @@ -1,8 +1,21 @@ -import { Parameter, ParameterValue, Tool, Command } from "@/components/commandly/types/flat"; -import { getCommandPath } from "@/components/commandly/utils/flat"; +import { ParameterValue, Tool, Command } from "@/components/commandly/types/flat"; +import { generateCommand } from "@/components/commandly/utils/flat"; import { Button } from "@/components/ui/button"; +import { CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; import { TerminalIcon, CopyIcon, SaveIcon } from "lucide-react"; -import { useCallback, useEffect, useState, useMemo } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ComponentProps, + type ReactNode, +} from "react"; import { toast } from "sonner"; interface GeneratedCommandProps { @@ -10,163 +23,204 @@ interface GeneratedCommandProps { selectedCommand?: Command | null; parameterValues: Record; onSaveCommand?: (command: string) => void; + useLongFlag?: boolean; + children?: ReactNode; } -export function GeneratedCommand({ +interface GeneratedCommandContextValue { + generatedCommand: string; + useLongFlag: boolean; + setUseLongFlag: (value: boolean) => void; + supportsFlagPreference: boolean; + onCopyCommand: () => void; + onSaveCommand?: (command: string) => void; +} + +const GeneratedCommandContext = createContext(null); + +function useGeneratedCommandContext() { + const context = useContext(GeneratedCommandContext); + + if (!context) { + throw new Error("GeneratedCommand compound components must be used within GeneratedCommand."); + } + + return context; +} + +function GeneratedCommandRoot({ tool, selectedCommand: providedCommand, parameterValues, onSaveCommand, + useLongFlag = false, + children, }: GeneratedCommandProps) { - const selectedCommand = providedCommand === undefined ? tool.commands[0] : providedCommand; - const hasCommands = tool.commands.length > 0; - const [generatedCommand, setGeneratedCommand] = useState(""); - - const globalParameters = useMemo(() => { - return tool.parameters?.filter((p) => p.isGlobal) || []; - }, [tool]); - - const rootParameters = useMemo(() => { - if (hasCommands && selectedCommand) return []; - return tool.parameters?.filter((p) => !p.commandKey && !p.isGlobal) || []; - }, [tool, hasCommands, selectedCommand]); - - const currentParameters = useMemo(() => { - if (!selectedCommand) return []; - return tool?.parameters?.filter((p) => p.commandKey === selectedCommand?.key) || []; - }, [tool, selectedCommand]); - - const generateCommand = useCallback(() => { - let command = tool.binaryName; - - if (hasCommands && selectedCommand) { - const commandPath = getCommandPath(selectedCommand, tool); - if (tool.binaryName !== commandPath) { - command = `${tool.binaryName} ${commandPath}`; - } - } - - const parametersWithValues: Array<{ - param: Parameter; - value: ParameterValue; - }> = []; - - globalParameters.forEach((param) => { - const value = parameterValues[param.key]; - if (value !== undefined && value !== "" && value !== false) { - parametersWithValues.push({ param, value }); - } - }); - - rootParameters.forEach((param) => { - const value = parameterValues[param.key]; - if (value !== undefined && value !== "" && value !== false) { - parametersWithValues.push({ param, value }); - } - }); - - currentParameters.forEach((param) => { - const value = parameterValues[param.key]; - if (value !== undefined && value !== "" && value !== false && !param.isGlobal) { - parametersWithValues.push({ param, value }); - } - }); - - const positionalParams = parametersWithValues - .filter(({ param }) => param.parameterType === "Argument") - .sort((a, b) => (a.param.position || 0) - (b.param.position || 0)); - - parametersWithValues.forEach(({ param, value }) => { - if (param.parameterType === "Flag") { - if (value === true) { - const flag = param.shortFlag || param.longFlag; - if (flag) command += ` ${flag}`; - } else if (param.isRepeatable && typeof value === "number" && value > 0) { - const flag = param.shortFlag || param.longFlag; - if (flag) command += ` ${flag}`.repeat(value); - } - } else if (param.parameterType === "Option") { - const flag = param.shortFlag || param.longFlag; - if (flag) { - const separator = param.keyValueSeparator ?? " "; - if (Array.isArray(value)) { - const entries = value.filter((v) => v !== ""); - if (entries.length > 0) { - if (param.arraySeparator) { - command += ` ${flag}${separator}${entries.join(param.arraySeparator)}`; - } else { - entries.forEach((v) => { - command += ` ${flag}${separator}${v}`; - }); - } - } - } else { - command += ` ${flag}${separator}${value}`; - } - } - } - }); - - positionalParams.forEach(({ value }) => { - if (!Array.isArray(value)) { - command += ` ${value}`; - } - }); - - setGeneratedCommand(command); - }, [ - tool, - parameterValues, - selectedCommand, - hasCommands, - globalParameters, - rootParameters, - currentParameters, - ]); + const [prefersLongFlag, setPrefersLongFlag] = useState(useLongFlag); + const supportsFlagPreference = useMemo( + () => + tool.parameters.some( + (parameter) => + parameter.parameterType !== "Argument" && + Boolean(parameter.shortFlag) && + Boolean(parameter.longFlag), + ), + [tool.parameters], + ); useEffect(() => { - generateCommand(); - }, [generateCommand]); + setPrefersLongFlag(useLongFlag); + }, [useLongFlag]); + + const generatedCommand = useMemo( + () => + generateCommand(tool, parameterValues, { + selectedCommand: providedCommand, + useLongFlag: prefersLongFlag, + }), + [tool, parameterValues, providedCommand, prefersLongFlag], + ); - const copyCommand = () => { + const copyCommand = useCallback(() => { navigator.clipboard.writeText(generatedCommand); toast("Command copied!"); - }; + }, [generatedCommand]); + + const contextValue = useMemo( + () => ({ + generatedCommand, + useLongFlag: prefersLongFlag, + setUseLongFlag: setPrefersLongFlag, + supportsFlagPreference, + onCopyCommand: copyCommand, + onSaveCommand, + }), + [generatedCommand, prefersLongFlag, supportsFlagPreference, copyCommand, onSaveCommand], + ); return ( -
+ {generatedCommand ? ( -
-
-
{generatedCommand}
+ (children ?? ( +
+ +
-
- - {onSaveCommand && ( - - )} -
-
+ )) ) : ( -
- -

Configure parameters to generate the command.

-
+ + )} + + ); +} + +function GeneratedCommandToolbar({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +function GeneratedCommandHeader({ + children, + className, + ...props +}: ComponentProps) { + return ( + + + + Generated Command + + {children} + + ); +} + +function GeneratedCommandFlagPreference({ + className, + ...props +}: Omit, "checked" | "onCheckedChange">) { + const { supportsFlagPreference, useLongFlag, setUseLongFlag } = useGeneratedCommandContext(); + + if (!supportsFlagPreference) return null; + + return ( + + ); +} + +function GeneratedCommandOutput({ className, ...props }: ComponentProps<"div">) { + const { generatedCommand } = useGeneratedCommandContext(); + + return ( +
+
{generatedCommand}
+
+ ); +} + +function GeneratedCommandActions({ className, ...props }: ComponentProps<"div">) { + const { generatedCommand, onCopyCommand, onSaveCommand } = useGeneratedCommandContext(); + + return ( +
+ + {onSaveCommand && ( + )}
); } + +function GeneratedCommandEmptyState() { + return ( +
+ +

Configure parameters to generate the command.

+
+ ); +} + +export const GeneratedCommand = Object.assign(GeneratedCommandRoot, { + Header: GeneratedCommandHeader, + Toolbar: GeneratedCommandToolbar, + FlagPreference: GeneratedCommandFlagPreference, + Output: GeneratedCommandOutput, + Actions: GeneratedCommandActions, + EmptyState: GeneratedCommandEmptyState, +}); diff --git a/registry/commandly/json-output.tsx b/registry/commandly/json-output.tsx index 670d1b5..40a180d 100644 --- a/registry/commandly/json-output.tsx +++ b/registry/commandly/json-output.tsx @@ -140,7 +140,7 @@ export function JsonOutput({ tool, originalTool, onApply }: JsonTypeComponentPro - + diff --git a/registry/commandly/utils/flat.ts b/registry/commandly/utils/flat.ts index 5eb8dfd..895b869 100644 --- a/registry/commandly/utils/flat.ts +++ b/registry/commandly/utils/flat.ts @@ -1,4 +1,12 @@ -import type { Command, Parameter, Tool } from "@/components/commandly/types/flat"; +import { SCHEMA_URL } from "@/components/ai-chat/tool-rules"; +import type { + Command, + ExclusionGroup, + Parameter, + ParameterMetadata, + ParameterValue, + Tool, +} from "@/components/commandly/types/flat"; export const slugify = (text: string): string => { return text @@ -59,17 +67,104 @@ export const getAllSubcommands = (commandKey: string, commands: Command[]): Comm return result; }; -const SCHEMA_URL = "https://commandly.divyeshio.in/specification/flat.json"; +export interface GenerateCommandOptions { + selectedCommand?: Command | null; + useLongFlag?: boolean; +} -export const sanitizeToolJSON = (tool: Tool) => { - const parameters = tool.parameters.map(({ metadata: _metadata, ...param }) => param); +function getPreferredFlag(param: Parameter, useLongFlag: boolean): string | undefined { + return useLongFlag ? param.longFlag || param.shortFlag : param.shortFlag || param.longFlag; +} - return { - $schema: SCHEMA_URL, - ...tool, - parameters, - }; -}; +export function generateCommand( + tool: Tool, + parameterValues: Record, + options: GenerateCommandOptions = {}, +): string { + const hasCommands = tool.commands.length > 0; + const selectedCommand = + options.selectedCommand === undefined ? (tool.commands[0] ?? null) : options.selectedCommand; + const useLongFlag = options.useLongFlag ?? false; + + let command = tool.binaryName; + + if (hasCommands && selectedCommand) { + const commandPath = getCommandPath(selectedCommand, tool); + if (tool.binaryName !== commandPath) { + command = `${tool.binaryName} ${commandPath}`; + } + } + + const parametersWithValues: Array<{ + param: Parameter; + value: ParameterValue; + }> = []; + const globalParameters = tool.parameters?.filter((param) => param.isGlobal) ?? []; + const rootParameters = + hasCommands && selectedCommand + ? [] + : (tool.parameters?.filter((param) => !param.commandKey && !param.isGlobal) ?? []); + const currentParameters = selectedCommand + ? (tool.parameters?.filter( + (param) => param.commandKey === selectedCommand.key && !param.isGlobal, + ) ?? []) + : []; + + [...globalParameters, ...rootParameters, ...currentParameters].forEach((param) => { + const value = parameterValues[param.key]; + if (value !== undefined && value !== "" && value !== false) { + parametersWithValues.push({ param, value }); + } + }); + + const positionalParams = parametersWithValues + .filter(({ param }) => param.parameterType === "Argument") + .sort((a, b) => (a.param.position || 0) - (b.param.position || 0)); + + parametersWithValues.forEach(({ param, value }) => { + if (param.parameterType === "Flag") { + if (value === true) { + const flag = getPreferredFlag(param, useLongFlag); + if (flag) command += ` ${flag}`; + } else if (param.isRepeatable && typeof value === "number" && value > 0) { + const flag = getPreferredFlag(param, useLongFlag); + if (flag) command += ` ${flag}`.repeat(value); + } + return; + } + + if (param.parameterType === "Option") { + const flag = getPreferredFlag(param, useLongFlag); + if (!flag) return; + + const separator = param.keyValueSeparator ?? " "; + if (Array.isArray(value)) { + const entries = value.filter((entry) => entry !== ""); + if (entries.length === 0) return; + + if (param.arraySeparator) { + command += ` ${flag}${separator}${entries.join(param.arraySeparator)}`; + return; + } + + entries.forEach((entry) => { + command += ` ${flag}${separator}${entry}`; + }); + return; + } + + command += ` ${flag}${separator}${value}`; + } + }); + + positionalParams.forEach(({ value }) => { + if (!Array.isArray(value)) { + command += ` ${value}`; + } + }); + + return command; +} export const exportToStructuredJSON = (tool: Tool) => { return { @@ -84,35 +179,37 @@ export const exportToStructuredJSON = (tool: Tool) => { }; }; -export const createNewParameter = (isGlobal: boolean, commandKey?: string): Parameter => { - return { - key: "", - name: "", - commandKey: isGlobal ? undefined : commandKey, - parameterType: "Option", - dataType: "String", - ...(isGlobal ? { isGlobal: true } : {}), - longFlag: "", - }; -}; +function isEmptyArray(value: unknown): boolean { + return Array.isArray(value) && value.length === 0; +} -const isEmpty = (value: object | null | undefined): boolean => { - if (value == null) return true; - if (Array.isArray(value)) return value.length === 0; - return Object.keys(value).length === 0; -}; +function isEmptyObject(value: unknown): boolean { + return ( + value != null && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === 0 + ); +} -const cleanParameter = (param: Parameter): Parameter => { +function cleanParameter(param: Parameter): Parameter { const cleaned = { ...param }; - if (!cleaned.enum || cleaned.enum.values.length === 0) delete cleaned.enum; - if (isEmpty(cleaned.validations)) delete cleaned.validations; - if (isEmpty(cleaned.dependencies)) delete cleaned.dependencies; + if (cleaned.isRequired === false) delete cleaned.isRequired; + if (cleaned.isRepeatable === false) delete cleaned.isRepeatable; + if (cleaned.isGlobal === false) delete cleaned.isGlobal; + if (cleaned.keyValueSeparator === " ") delete cleaned.keyValueSeparator; + if (cleaned.arraySeparator === "," && cleaned.isRepeatable !== true) + delete cleaned.arraySeparator; + + if (!cleaned.enum || isEmptyArray(cleaned.enum.values)) delete cleaned.enum; + if (isEmptyArray(cleaned.validations)) delete cleaned.validations; + if (isEmptyArray(cleaned.dependencies)) delete cleaned.dependencies; if (cleaned.metadata) { - const meta = { ...cleaned.metadata }; - if (isEmpty(meta.tags)) delete meta.tags; - if (isEmpty(meta)) { + const meta = { ...cleaned.metadata } as ParameterMetadata; + if (isEmptyArray(meta.tags)) delete meta.tags; + if (isEmptyObject(meta)) { delete cleaned.metadata; } else { cleaned.metadata = meta; @@ -120,15 +217,71 @@ const cleanParameter = (param: Parameter): Parameter => { } return cleaned; -}; +} -export const cleanupTool = (tool: Tool): Tool => { - const cleaned = { ...tool }; +function cleanCommand(cmd: Command): Command { + const cleaned = { ...cmd }; + if (cleaned.interactive === false) delete cleaned.interactive; + return cleaned; +} - if (isEmpty(cleaned.exclusionGroups)) delete cleaned.exclusionGroups; - if (isEmpty(cleaned.metadata)) delete cleaned.metadata; +function cleanExclusionGroup(group: ExclusionGroup): ExclusionGroup { + return { ...group }; +} - cleaned.parameters = cleaned.parameters.map(cleanParameter); +export interface FixToolOptions { + addSchema?: boolean; + removeMetadata?: boolean; +} - return cleaned; -}; +export function fixTool(tool: Tool, options?: FixToolOptions): Tool { + const addSchema = options?.addSchema ?? false; + const removeMetadata = options?.removeMetadata ?? false; + + const cleaned: Record = { ...tool }; + + if (addSchema) { + cleaned["$schema"] = SCHEMA_URL; + } + + if (cleaned.interactive === false) delete cleaned.interactive; + if (isEmptyObject(cleaned.metadata) || removeMetadata) delete cleaned.metadata; + if (isEmptyArray(cleaned.exclusionGroups)) delete cleaned.exclusionGroups; + + if ("description" in cleaned && cleaned.info == null) { + cleaned.info = { description: cleaned.description as string }; + delete cleaned.description; + } + + if ("version" in cleaned && typeof cleaned.version === "string") { + if (cleaned.info && typeof cleaned.info === "object") { + (cleaned.info as Record).version = cleaned.version; + } else { + cleaned.info = { version: cleaned.version as string }; + } + delete cleaned.version; + } + + if (Array.isArray(cleaned.commands)) { + cleaned.commands = (cleaned.commands as Command[]).map(cleanCommand); + } + + if (Array.isArray(cleaned.parameters)) { + cleaned.parameters = (cleaned.parameters as Parameter[]).map((p) => { + const fixed = cleanParameter(p); + if (removeMetadata) delete fixed.metadata; + return fixed; + }); + } + + if ( + Array.isArray(cleaned.exclusionGroups) && + (cleaned.exclusionGroups as ExclusionGroup[]).length > 0 + ) { + cleaned.exclusionGroups = (cleaned.exclusionGroups as ExclusionGroup[]).map( + cleanExclusionGroup, + ); + } + + return cleaned as unknown as Tool; +} diff --git a/registry/commandly/utils/tool-validation.ts b/registry/commandly/utils/tool-validation.ts new file mode 100644 index 0000000..7788530 --- /dev/null +++ b/registry/commandly/utils/tool-validation.ts @@ -0,0 +1,302 @@ +import type { Tool, Parameter, Command, ExclusionGroup } from "@/components/commandly/types/flat"; + +export type ValidationSeverity = "error" | "warning"; + +export interface ToolValidationError { + path: string; + message: string; + severity: ValidationSeverity; +} + +function checkUniqueKeys( + items: { key: string }[], + label: string, + pathPrefix: string, +): ToolValidationError[] { + const errors: ToolValidationError[] = []; + const seen = new Map(); + + for (let i = 0; i < items.length; i++) { + const key = items[i].key; + if (!key) { + errors.push({ + path: `${pathPrefix}[${i}].key`, + message: `${label} at index ${i} has an empty key`, + severity: "error", + }); + continue; + } + const prev = seen.get(key); + if (prev !== undefined) { + errors.push({ + path: `${pathPrefix}[${i}].key`, + message: `Duplicate ${label.toLowerCase()} key "${key}" (first at index ${prev})`, + severity: "error", + }); + } else { + seen.set(key, i); + } + } + return errors; +} + +function validateParameter(param: Parameter, index: number, tool: Tool): ToolValidationError[] { + const errors: ToolValidationError[] = []; + const path = `parameters[${index}]`; + const hasCommands = tool.commands.length > 0; + + if (!param.name) { + errors.push({ + path: `${path}.name`, + message: `Parameter "${param.key}" has no name`, + severity: "error", + }); + } + + if (!param.parameterType) { + errors.push({ + path: `${path}.parameterType`, + message: `Parameter "${param.key}" has no parameterType`, + severity: "error", + }); + } + + if (!param.dataType) { + errors.push({ + path: `${path}.dataType`, + message: `Parameter "${param.key}" has no dataType`, + severity: "error", + }); + } + + if (param.parameterType === "Flag" && param.dataType !== "Boolean") { + errors.push({ + path: `${path}.dataType`, + message: `Flag "${param.key}" should have dataType "Boolean" but has "${param.dataType}"`, + severity: "warning", + }); + } + + if (hasCommands && !param.commandKey && !param.isGlobal) { + errors.push({ + path: `${path}`, + message: `Parameter "${param.key}" must have commandKey or isGlobal when commands exist`, + severity: "error", + }); + } + + if (!hasCommands && (param.commandKey || param.isGlobal)) { + errors.push({ + path: `${path}`, + message: `Parameter "${param.key}" must not have commandKey or isGlobal when there are no commands`, + severity: "error", + }); + } + + if (param.isGlobal && param.commandKey) { + errors.push({ + path: `${path}`, + message: `Parameter "${param.key}" has both isGlobal and commandKey - global parameters must not have commandKey`, + severity: "error", + }); + } + + if (param.commandKey && !tool.commands.some((c) => c.key === param.commandKey)) { + errors.push({ + path: `${path}.commandKey`, + message: `Parameter "${param.key}" references non-existent command "${param.commandKey}"`, + severity: "error", + }); + } + + if (param.dataType === "Enum") { + if (!param.enum || param.enum.values.length === 0) { + errors.push({ + path: `${path}.enum`, + message: `Enum parameter "${param.key}" has no enum values`, + severity: "error", + }); + } else { + for (let j = 0; j < param.enum.values.length; j++) { + const ev = param.enum.values[j]; + if (!ev.value) { + errors.push({ + path: `${path}.enum.values[${j}].value`, + message: `Enum value at index ${j} in parameter "${param.key}" has no value`, + severity: "error", + }); + } + if (!ev.displayName) { + errors.push({ + path: `${path}.enum.values[${j}].displayName`, + message: `Enum value "${ev.value}" in parameter "${param.key}" has no displayName`, + severity: "error", + }); + } + } + } + } + + if (param.validations) { + const valKeys = new Set(); + for (let j = 0; j < param.validations.length; j++) { + const v = param.validations[j]; + if (valKeys.has(v.key)) { + errors.push({ + path: `${path}.validations[${j}].key`, + message: `Duplicate validation key "${v.key}" in parameter "${param.key}"`, + severity: "error", + }); + } + valKeys.add(v.key); + } + } + + if (param.dependencies) { + const paramKeys = new Set(tool.parameters.map((p) => p.key)); + for (let j = 0; j < param.dependencies.length; j++) { + const dep = param.dependencies[j]; + if (!paramKeys.has(dep.dependsOnParameterKey)) { + errors.push({ + path: `${path}.dependencies[${j}].dependsOnParameterKey`, + message: `Dependency in "${param.key}" references non-existent parameter "${dep.dependsOnParameterKey}"`, + severity: "error", + }); + } + } + } + + return errors; +} + +function validateCommand(cmd: Command, index: number, tool: Tool): ToolValidationError[] { + const errors: ToolValidationError[] = []; + const path = `commands[${index}]`; + + if (!cmd.name) { + errors.push({ + path: `${path}.name`, + message: `Command "${cmd.key}" has no name`, + severity: "error", + }); + } + + if (cmd.parentCommandKey && !tool.commands.some((c) => c.key === cmd.parentCommandKey)) { + errors.push({ + path: `${path}.parentCommandKey`, + message: `Command "${cmd.key}" references non-existent parent command "${cmd.parentCommandKey}"`, + severity: "error", + }); + } + + return errors; +} + +function validateExclusionGroups(groups: ExclusionGroup[], tool: Tool): ToolValidationError[] { + const errors: ToolValidationError[] = []; + const paramKeys = new Set(tool.parameters.map((p) => p.key)); + + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + const path = `exclusionGroups[${i}]`; + + if (!group.name) { + errors.push({ + path: `${path}.name`, + message: `Exclusion group at index ${i} has no name`, + severity: "error", + }); + } + + if (!group.parameterKeys || group.parameterKeys.length < 2) { + errors.push({ + path: `${path}.parameterKeys`, + message: `Exclusion group "${group.name}" must reference at least 2 parameters`, + severity: "error", + }); + } else { + for (const pk of group.parameterKeys) { + if (!paramKeys.has(pk)) { + errors.push({ + path: `${path}.parameterKeys`, + message: `Exclusion group "${group.name}" references non-existent parameter "${pk}"`, + severity: "error", + }); + } + } + } + + if (group.commandKey && !tool.commands.some((c) => c.key === group.commandKey)) { + errors.push({ + path: `${path}.commandKey`, + message: `Exclusion group "${group.name}" references non-existent command "${group.commandKey}"`, + severity: "error", + }); + } + } + + return errors; +} + +export function validateTool(tool: Tool): ToolValidationError[] { + const errors: ToolValidationError[] = []; + + if (!tool.binaryName) { + errors.push({ path: "binaryName", message: "Tool has no binaryName", severity: "error" }); + } + + if (!tool.displayName) { + errors.push({ path: "displayName", message: "Tool has no displayName", severity: "error" }); + } + + if (!Array.isArray(tool.commands)) { + errors.push({ path: "commands", message: "commands must be an array", severity: "error" }); + } else { + errors.push(...checkUniqueKeys(tool.commands, "Command", "commands")); + for (let i = 0; i < tool.commands.length; i++) { + errors.push(...validateCommand(tool.commands[i], i, tool)); + } + } + + if (!Array.isArray(tool.parameters)) { + errors.push({ path: "parameters", message: "parameters must be an array", severity: "error" }); + } else { + errors.push(...checkUniqueKeys(tool.parameters, "Parameter", "parameters")); + for (let i = 0; i < tool.parameters.length; i++) { + errors.push(...validateParameter(tool.parameters[i], i, tool)); + } + } + + if (tool.exclusionGroups && tool.exclusionGroups.length > 0) { + errors.push(...validateExclusionGroups(tool.exclusionGroups, tool)); + } + + if ("description" in tool && !(tool as Record)["info"]) { + errors.push({ + path: "description", + message: "Tool description should be nested under info object, not at top level", + severity: "warning", + }); + } + + if ("version" in tool && !(tool as Record)["info"]) { + errors.push({ + path: "version", + message: "Tool version should be nested under info object, not at top level", + severity: "warning", + }); + } + + return errors; +} + +export function formatValidationErrors(errors: ToolValidationError[]): string { + if (errors.length === 0) return ""; + return errors + .map((e) => `${e.severity === "error" ? "❌" : "⚠️"} ${e.path}: ${e.message}`) + .join("\n"); +} + +export function hasErrors(errors: ToolValidationError[]): boolean { + return errors.some((e) => e.severity === "error"); +} diff --git a/scripts/validate-tool-collection.ts b/scripts/validate-tool-collection.ts index fb94afe..aa632b3 100644 --- a/scripts/validate-tool-collection.ts +++ b/scripts/validate-tool-collection.ts @@ -2,7 +2,12 @@ import { readFileSync, writeFileSync } from "fs"; import { basename, resolve } from "path"; import type { Tool } from "@/components/commandly/types/flat"; -import { sanitizeToolJSON } from "@/components/commandly/utils/flat"; +import { fixTool } from "@/components/commandly/utils/flat"; +import { + validateTool, + formatValidationErrors, + hasErrors, +} from "@/components/commandly/utils/tool-validation"; import Ajv from "ajv"; const schemaPath = resolve("public/specification/flat.json"); @@ -35,7 +40,7 @@ for (const file of files) { try { tool = JSON.parse(raw) as Tool; } catch (e) { - errors.push(`❌ \`${file}\`: Invalid JSON — ${(e as Error).message}`); + errors.push(`❌ \`${file}\`: Invalid JSON - ${(e as Error).message}`); continue; } @@ -54,27 +59,14 @@ for (const file of files) { continue; } - if (!Array.isArray(tool.commands)) { - errors.push(`❌ \`${file}\`: \`commands\` must be an array.`); + const toolErrors = validateTool(tool); + if (hasErrors(toolErrors)) { + errors.push(`❌ \`${file}\`:\n${formatValidationErrors(toolErrors)}`); continue; } - const hasCommands = tool.commands.length > 0; - for (const param of tool.parameters) { - if (!hasCommands && (param.commandKey || param.isGlobal)) { - errors.push( - `❌ \`${file}\`: Parameter \`${param.key}\` must not have \`commandKey\` or \`isGlobal\` when there are no commands.`, - ); - } - if (hasCommands && !param.commandKey && !param.isGlobal) { - errors.push( - `❌ \`${file}\`: Parameter \`${param.key}\` must have \`commandKey\` or \`isGlobal\` when commands exist.`, - ); - } - } - - const sanitized = sanitizeToolJSON(tool); - const output = JSON.stringify(sanitized, null, 2); + const fixed = fixTool(tool, { addSchema: true, removeMetadata: true }); + const output = JSON.stringify(fixed, null, 2); if (output !== raw.trimEnd()) { writeFileSync(file, output + "\n", "utf-8"); diff --git a/src/components/ai-chat/tool-rules.ts b/src/components/ai-chat/tool-rules.ts new file mode 100644 index 0000000..7ad7292 --- /dev/null +++ b/src/components/ai-chat/tool-rules.ts @@ -0,0 +1,57 @@ +export const SCHEMA_URL = "https://commandly.divyeshio.in/specification/flat.json"; + +export const SORTING_RULES = `1. Sort all parameters, grouping similar ones together (e.g., output options, filter options, connection options) +2. Update parameter names and descriptions to be consistent within each group +3. Parameters related to verbose, debug, or logging should be at the end +4. Positional arguments should be sorted by their position field +5. Global parameters should be listed before command-specific parameters`; + +export const GROUPING_RULES = `1. Group similar parameters using the group field (e.g., Output, Filter, Connection, Debug) +2. Output-related parameters (format, output, json, csv) should be grouped together +3. Network/connection parameters (timeout, proxy, rate-limit) should be grouped together +4. Filtering parameters (include, exclude, match) should be grouped together +5. Verbose, debug, silent, and logging parameters should be grouped at the end`; + +export const TYPE_FIX_RULES = `1. Flags (boolean switches) must have parameterType Flag and dataType Boolean +2. Options that accept enum values must have dataType Enum with valid enum values +3. Options that accept numbers (port, timeout, count, limit, rate) should have dataType Number +4. Options that accept file paths, URLs, or free text should have dataType String +5. If a parameter accepts multiple values packed into a single argument separated by a character (e.g. --hosts a,b,c), set arraySeparator to that character - do NOT set isRepeatable +6. If a parameter can be specified multiple times as separate flags (e.g. --host a --host b --host c), set isRepeatable - do NOT use arraySeparator +7. If a parameter uses key=value syntax, set keyValueSeparator +8. Mark parameters as isRequired when they must be provided`; + +export const VALIDATION_RULES = `1. All parameter keys must be unique across the tool +2. All command keys must be unique across the tool +3. Parameters with isGlobal must not have commandKey +4. Parameters without isGlobal must have commandKey when multiple commands exist +5. Enum parameters must have at least one enum value +6. Enum values must have both value and displayName +7. Validation keys must be unique within a parameter +8. Dependency parameterKey and dependsOnParameterKey must reference existing parameters +9. Exclusion group parameterKeys must reference existing parameters +10. Descriptions should be in sentence case +11. Do not add empty arrays or objects for optional properties +12. Tool description and version must be nested under an info object`; + +export const PROMPT_PILLS = [ + { + label: "Sorting & Grouping", + text: `Sort all parameters, grouping similar ones together (e.g., output options, filter options, connection options). Update parameter names and descriptions to be consistent within each group. Anything related to verbose, debug, or logging should be at the end.`, + }, + { + label: "Update from docs", + text: "Update this tool's description, parameter descriptions, and types to accurately reflect the official documentation. Make descriptions concise and in sentence case.", + }, + { + label: "Fix types & validation", + text: "Fix parameter types (string, number, boolean, array), mark required parameters correctly, and add appropriate validation rules where needed.", + }, +]; + +export const ALL_RULES = { + sorting: SORTING_RULES, + grouping: GROUPING_RULES, + typeFix: TYPE_FIX_RULES, + validation: VALIDATION_RULES, +}; diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx index 39109e4..77a62cc 100644 --- a/src/components/ai-elements/code-block.tsx +++ b/src/components/ai-elements/code-block.tsx @@ -389,7 +389,7 @@ export const CodeBlockContent = ({ // Memoized raw tokens for immediate display const rawTokens = useMemo(() => createRawTokens(code), [code]); - // Synchronous cache lookup — avoids setState in effect for cached results + // Synchronous cache lookup - avoids setState in effect for cached results const syncTokens = useMemo( () => highlightCode(code, language) ?? rawTokens, [code, language, rawTokens], diff --git a/src/components/ai-elements/context.tsx b/src/components/ai-elements/context.tsx index 96128a9..94535db 100644 --- a/src/components/ai-elements/context.tsx +++ b/src/components/ai-elements/context.tsx @@ -230,7 +230,7 @@ export const ContextContentFooter = ({ const TokensWithCost = ({ tokens, costText }: { tokens?: number; costText?: string }) => ( {tokens === undefined - ? "—" + ? "-" : new Intl.NumberFormat("en-US", { notation: "compact", }).format(tokens)} diff --git a/src/components/docs/demos/generated-command-demo.tsx b/src/components/docs/demos/generated-command-demo.tsx index 0315351..c97031f 100644 --- a/src/components/docs/demos/generated-command-demo.tsx +++ b/src/components/docs/demos/generated-command-demo.tsx @@ -1,5 +1,6 @@ import { GeneratedCommand } from "@/components/commandly/generated-command"; import type { Tool } from "@/components/commandly/types/flat"; +import { Card, CardContent } from "@/components/ui/card"; import { useState } from "react"; const sampleTool: Tool = { @@ -61,12 +62,22 @@ export function GeneratedCommandDemo() { return (
- console.log("Saved:", cmd)} - /> + + console.log("Saved:", cmd)} + > + + + + + + + + +
); } diff --git a/src/components/theme-switcher.tsx b/src/components/theme-switcher.tsx index 033b947..f1107dc 100644 --- a/src/components/theme-switcher.tsx +++ b/src/components/theme-switcher.tsx @@ -1,15 +1,20 @@ import { Button } from "./ui/button"; import { MoonIcon, SunIcon } from "lucide-react"; import { useTheme } from "next-themes"; -import { useRef } from "react"; +import { useEffect, useRef, useState } from "react"; export type Theme = "dark" | "light" | "system"; export function ThemeSwitcher() { const ref = useRef(null); + const [mounted, setMounted] = useState(false); const { theme, setTheme } = useTheme(); + useEffect(() => { + setMounted(true); + }, []); + const toggleDarkMode = async (theme: Theme) => { /** * Return early if View Transition API is not supported @@ -54,8 +59,10 @@ export function ThemeSwitcher() { className="rounded-full" onClick={() => (theme === "dark" ? toggleDarkMode("light") : toggleDarkMode("dark"))} ref={ref} + aria-label="Toggle theme" + suppressHydrationWarning > - {theme === "dark" ? ( + {mounted && theme === "dark" ? ( ) : ( diff --git a/src/components/tool-card.tsx b/src/components/tool-card.tsx index 11553b2..5a0a352 100644 --- a/src/components/tool-card.tsx +++ b/src/components/tool-card.tsx @@ -95,7 +95,7 @@ export function ToolCard({ diff --git a/src/components/tool-editor/ai-chat.tsx b/src/components/tool-editor/ai-chat.tsx index 89d8efe..f9709e4 100644 --- a/src/components/tool-editor/ai-chat.tsx +++ b/src/components/tool-editor/ai-chat.tsx @@ -26,6 +26,7 @@ import { createTavilyExtractTool, createTavilySearchTool, } from "./tools"; +import { PROMPT_PILLS } from "@/components/ai-chat/tool-rules"; import { ChainOfThought, ChainOfThoughtContent, @@ -111,21 +112,6 @@ import { import { useMemo, useState, useSyncExternalStore } from "react"; import { toast } from "sonner"; -const PROMPT_PILLS = [ - { - label: "Sorting & Grouping", - text: "Sort all parameters, grouping similar ones together (e.g., output options, filter options, connection options). Update parameter names and descriptions to be consistent within each group. Anything related to verbose, debug, or logging should be at the end", - }, - { - label: "Update from docs", - text: "Update this tool's description, parameter descriptions, and types to accurately reflect the official documentation. Make descriptions concise and in sentence case.", - }, - { - label: "Fix types & validation", - text: "Fix parameter types (string, number, boolean, array), mark required parameters correctly, and add appropriate validation rules where needed.", - }, -]; - const MODEL_MAX_TOKENS: Record = { "claude-opus-4-7": 200000, "claude-opus-4-5": 200000, @@ -289,17 +275,14 @@ function useAIChat( }, ); - const applyToolDefinitionDef = createApplyToolDefinitionTool( - () => {}, - function onApplyExecuted() { - const preview = store.getPendingPreview(); - if (preview) { - onApply(replaceKey(preview) as Tool); - store.setPendingPreview(null); - } - onStreamingTool?.(null); - }, - ); + const applyToolDefinitionDef = createApplyToolDefinitionTool(function onApplyExecuted() { + const preview = store.getPendingPreview(); + if (preview) { + onApply(replaceKey(preview) as Tool); + store.setPendingPreview(null); + } + onStreamingTool?.(null); + }); const tools = { editTool: editToolDef, @@ -869,7 +852,7 @@ export function AIChatPanel({ diff --git a/src/components/tool-editor/command-tree.tsx b/src/components/tool-editor/command-tree.tsx index 64b3bd7..2bd83ec 100644 --- a/src/components/tool-editor/command-tree.tsx +++ b/src/components/tool-editor/command-tree.tsx @@ -289,6 +289,7 @@ export function CommandTree({ isChatOpen = false }: { isChatOpen?: boolean }) { element={rootElement} isSelect={isRootSelected} className="group px-2 py-1.5 font-medium" + hasChildren={rootCommands.length > 0} actions={ handleAddSubcommand()} />} onClick={handleRootClick} > diff --git a/src/components/tool-editor/dialogs/parameter-details-dialog.tsx b/src/components/tool-editor/dialogs/parameter-details-dialog.tsx index 39f380a..3f042fe 100644 --- a/src/components/tool-editor/dialogs/parameter-details-dialog.tsx +++ b/src/components/tool-editor/dialogs/parameter-details-dialog.tsx @@ -11,7 +11,7 @@ import { ParameterValidationType, } from "@/components/commandly/types/flat"; import { TagsInput } from "@/components/commandly/ui/tags-input"; -import { createNewParameter, slugify } from "@/components/commandly/utils/flat"; +import { slugify } from "@/components/commandly/utils/flat"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -76,7 +76,16 @@ export function ParameterDetailsDialog({ const isNewParameter = !parameter; const [internalParameter, setParameter] = useState( - () => parameter ?? createNewParameter(isGlobal, commandKey), + () => + parameter ?? { + key: "", + name: "", + commandKey: isGlobal ? undefined : commandKey, + parameterType: "Option" as ParameterType, + dataType: "String" as ParameterDataType, + ...(isGlobal ? { isGlobal: true as const } : {}), + longFlag: "", + }, ); const [hasChanges, setHasChanges] = useState(false); const enumValueIdsRef = useRef( diff --git a/src/components/tool-editor/dialogs/saved-commands-dialog.tsx b/src/components/tool-editor/dialogs/saved-commands-dialog.tsx index ecea461..c019bf5 100644 --- a/src/components/tool-editor/dialogs/saved-commands-dialog.tsx +++ b/src/components/tool-editor/dialogs/saved-commands-dialog.tsx @@ -62,7 +62,7 @@ export function SavedCommandsDialog({ savedCommands.map((savedCommand) => (
-
-                    {savedCommand.command}
-                  
+
+
{savedCommand.command}
+
))}
diff --git a/src/components/tool-editor/dialogs/tool-details-dialog.tsx b/src/components/tool-editor/dialogs/tool-details-dialog.tsx index c3c18a3..95ab6d0 100644 --- a/src/components/tool-editor/dialogs/tool-details-dialog.tsx +++ b/src/components/tool-editor/dialogs/tool-details-dialog.tsx @@ -1,5 +1,4 @@ import { useToolBuilder } from "../tool-editor.context"; -import { ToolMetadata } from "@/components/commandly/types/flat"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -10,22 +9,10 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { MultiSelect } from "@/components/ui/multi-select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import { SupportedToolInputType, SupportedToolOutputType } from "@/lib/types"; import { SettingsIcon } from "lucide-react"; -const supportedInputOptions = [ - { value: "StandardInput", label: "Standard Input" }, - { value: "Parameter", label: "Parameter" }, -]; - -const supportedOutputOptions = [ - { value: "StandardOutput", label: "Standard Output" }, - { value: "File", label: "File" }, -]; - export function ToolDetailsDialog() { const { tool, dialogs, setDialogOpen, updateTool } = useToolBuilder(); @@ -86,44 +73,6 @@ export function ToolDetailsDialog() { />
-
-
- - - updateTool({ - metadata: { - ...tool.metadata, - supportedInput: value.map((v) => v as SupportedToolInputType), - } as ToolMetadata, - }) - } - defaultValue={tool.metadata?.supportedInput} - placeholder="Select input types" - variant="default" - maxCount={0} - /> -
-
- - - updateTool({ - metadata: { - ...tool.metadata, - supportedOutput: value.map((v) => v as SupportedToolOutputType), - } as ToolMetadata, - }) - } - defaultValue={tool.metadata?.supportedOutput} - placeholder="Select output types" - variant="default" - maxCount={0} - /> -
-
diff --git a/src/components/tool-editor/help-menu.tsx b/src/components/tool-editor/help-menu.tsx index 94ea664..38addd2 100644 --- a/src/components/tool-editor/help-menu.tsx +++ b/src/components/tool-editor/help-menu.tsx @@ -1,124 +1,177 @@ import { useToolBuilder } from "./tool-editor.context"; -import { Command } from "@/components/commandly/types/flat"; +import type { Command, Parameter, Tool } from "@/components/commandly/types/flat"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +const formatDescription = (description?: string) => (description?.trim() ? ` ${description}` : ""); + +function formatFlagStr(param: Parameter): string { + const short = param.shortFlag ?? ""; + const long = param.longFlag ?? ""; + return short && long ? `${short}, ${long}` : short || long; +} + +function formatEnumValues(param: Parameter, indent: string, pad: number): string { + if (!param.enum?.values?.length) return ""; + const values = param.enum.values.map((e) => e.value).join(", "); + let result = `${indent}${" ".repeat(pad)} Values: ${values}\n`; + if (param.enum.allowMultiple) { + result += `${indent}${" ".repeat(pad)} Multiple: yes (separator: "${param.enum.separator ?? ","}")\n`; + } + return result; +} + +function formatValidations(param: Parameter, indent: string, pad: number): string { + if (!param.validations?.length) return ""; + return ( + param.validations + .map( + (v) => `${indent}${" ".repeat(pad)} Validation: ${v.validationType}=${v.validationValue}`, + ) + .join("\n") + "\n" + ); +} + +function formatDependencies(param: Parameter, indent: string, pad: number): string { + if (!param.dependencies?.length) return ""; + return ( + param.dependencies + .map( + (d) => + `${indent}${" ".repeat(pad)} ${d.dependencyType === "requires" ? "Requires" : "Conflicts with"}: ${d.dependsOnParameterKey}${d.conditionValue ? `=${d.conditionValue}` : ""}`, + ) + .join("\n") + "\n" + ); +} + +function formatParamDetails(param: Parameter, indent: string, pad: number): string { + let result = ""; + if (param.dataType === "Enum" || param.enum?.values?.length) { + result += formatEnumValues(param, indent, pad); + } + result += formatValidations(param, indent, pad); + result += formatDependencies(param, indent, pad); + return result; +} + +export function generateToolPreview(tool: Tool): string { + const rootCommands = tool.commands.filter((cmd) => !cmd.parentCommandKey); + const globalParams = tool.parameters.filter((p) => p.isGlobal); + + let preview = `${tool.displayName}${tool.info?.version ? ` v${tool.info.version}` : ""}\n`; + preview += `${tool.info?.description ?? ""}\n\n`; + + preview += `USAGE:\n`; + preview += ` ${tool.binaryName} [GLOBAL OPTIONS] [OPTIONS] [ARGUMENTS]\n\n`; + + if (globalParams.length > 0) { + preview += "GLOBAL OPTIONS:\n"; + + const globalFlags = globalParams.filter((p) => p.parameterType === "Flag"); + const globalOptions = globalParams.filter((p) => p.parameterType === "Option"); + const globalArguments = globalParams.filter((p) => p.parameterType === "Argument"); + + globalFlags.forEach((flag) => { + const flagStr = formatFlagStr(flag); + const required = flag.isRequired ? "Required: " : ""; + preview += ` ${flagStr.padEnd(20)} ${required}${flag.description ?? ""}\n`; + preview += formatParamDetails(flag, " ", 20); + }); + + globalOptions.forEach((option) => { + const flagStr = formatFlagStr(option); + const valueType = option.dataType === "Enum" ? `` : `<${option.dataType}>`; + const required = option.isRequired ? "Required: " : ""; + preview += ` ${flagStr.padEnd(20)} ${required}${option.description ?? ""}\n`; + preview += ` ${" ".repeat(20)} Value: ${valueType}\n`; + preview += formatParamDetails(option, " ", 20); + }); + + globalArguments.forEach((arg) => { + const required = arg.isRequired ? "Required: " : ""; + preview += ` ${arg.name.padEnd(20)} ${required}${arg.description ?? ""}\n`; + preview += formatParamDetails(arg, " ", 20); + }); + + preview += "\n"; + } + + if (rootCommands.length > 0) { + preview += "COMMANDS:\n"; + const printCommand = (command: Command, level = 0) => { + const indent = " ".repeat(level + 1); + preview += `${indent}${command.name.padEnd(20 - level * 2)}${formatDescription(command.description)}\n`; + + const commandParams = tool.parameters.filter( + (p) => !p.isGlobal && p.commandKey === command.key, + ); + + const flags = commandParams.filter((p) => p.parameterType === "Flag"); + const options = commandParams.filter((p) => p.parameterType === "Option"); + const arguments_ = commandParams.filter((p) => p.parameterType === "Argument"); + + if (flags.length > 0) { + preview += `${indent} Flags:\n`; + flags.forEach((flag) => { + const flagStr = formatFlagStr(flag); + const required = flag.isRequired ? "Required: " : ""; + preview += `${indent} ${flagStr.padEnd(18)} ${required}${flag.description ?? ""}\n`; + preview += formatParamDetails(flag, `${indent} `, 18); + }); + } + + if (options.length > 0) { + preview += `${indent} Options:\n`; + options.forEach((option) => { + const flagStr = formatFlagStr(option); + const valueType = option.dataType === "Enum" ? `` : `<${option.dataType}>`; + const required = option.isRequired ? "Required: " : ""; + preview += `${indent} ${flagStr.padEnd(18)} ${required}${option.description ?? ""}\n`; + preview += `${indent} ${" ".repeat(18)} Value: ${valueType}\n`; + preview += formatParamDetails(option, `${indent} `, 18); + }); + } + + if (arguments_.length > 0) { + preview += `${indent} Arguments:\n`; + arguments_.forEach((arg) => { + const required = arg.isRequired ? "Required: " : ""; + preview += `${indent} ${arg.name.padEnd(18)} ${required}${arg.description ?? ""}\n`; + preview += formatParamDetails(arg, `${indent} `, 18); + }); + } + + const subcommands = tool.commands.filter((cmd) => cmd.parentCommandKey === command.key); + if (subcommands.length > 0) { + preview += `${indent} Subcommands:\n`; + subcommands.forEach((subcmd) => { + printCommand(subcmd, level + 2); + }); + } + }; + + rootCommands.forEach((cmd) => printCommand(cmd)); + } + + if (tool.exclusionGroups && tool.exclusionGroups.length > 0) { + preview += "\nEXCLUSION GROUPS:\n"; + tool.exclusionGroups.forEach((group) => { + const type = + group.exclusionType === "mutual_exclusive" ? "Mutually exclusive" : "Required one of"; + preview += ` ${group.name}: ${type}\n`; + preview += ` Parameters: ${group.parameterKeys.join(", ")}\n`; + }); + } + + return preview; +} + export function HelpMenu() { const { tool } = useToolBuilder(); - const formatDescription = (description?: string) => - description?.trim() ? ` ${description}` : ""; - - const generateToolPreview = (): string => { - const rootCommands = tool.commands.filter((cmd) => !cmd.parentCommandKey); - const globalParams = tool.parameters.filter((p) => p.isGlobal); - - let preview = `${tool.displayName}${tool.info?.version ? ` v${tool.info.version}` : ""}\n`; - preview += `${tool.info?.description ?? ""}\n\n`; - - preview += `USAGE:\n`; - preview += ` ${tool.binaryName} [GLOBAL OPTIONS] [OPTIONS] [ARGUMENTS]\n\n`; - - if (globalParams.length > 0) { - preview += "GLOBAL OPTIONS:\n"; - - const globalFlags = globalParams.filter((p) => p.parameterType === "Flag"); - const globalOptions = globalParams.filter((p) => p.parameterType === "Option"); - - globalFlags.forEach((flag) => { - const shortFlag = flag.shortFlag ? `${flag.shortFlag}` : ""; - const longFlag = flag.longFlag ? `${flag.longFlag}` : ""; - const flagStr = shortFlag && longFlag ? `${shortFlag}, ${longFlag}` : shortFlag || longFlag; - const required = flag.isRequired ? "Required: " : ""; - preview += ` ${flagStr.padEnd(20)} ${required}${flag.description}\n`; - }); - - globalOptions.forEach((option) => { - const shortFlag = option.shortFlag ? `${option.shortFlag}` : ""; - const longFlag = option.longFlag ? `${option.longFlag}` : ""; - const flagStr = shortFlag && longFlag ? `${shortFlag}, ${longFlag}` : shortFlag || longFlag; - const valueType = option.dataType.includes("array") - ? `` - : `<${option.dataType}>`; - const required = option.isRequired ? "Required: " : ""; - preview += ` ${flagStr.padEnd(20)} ${required}${option.description}\n`; - preview += ` ${" ".repeat(20)} Value: ${valueType}\n`; - }); - - preview += "\n"; - } - - if (rootCommands.length > 0) { - preview += "COMMANDS:\n"; - const printCommand = (command: Command, level = 0) => { - const indent = " ".repeat(level + 1); - preview += `${indent}${command.name.padEnd(20 - level * 2)}${formatDescription(command.description)}\n`; - - const commandParams = tool.parameters.filter( - (p) => !p.isGlobal && p.commandKey === command.key, - ); - - const flags = commandParams.filter((p) => p.parameterType === "Flag"); - const options = commandParams.filter((p) => p.parameterType === "Option"); - const arguments_ = commandParams.filter((p) => p.parameterType === "Argument"); - - if (flags.length > 0) { - preview += `${indent} Flags:\n`; - flags.forEach((flag) => { - const shortFlag = flag.shortFlag ? `${flag.shortFlag}` : ""; - const longFlag = flag.longFlag ? `${flag.longFlag}` : ""; - const flagStr = - shortFlag && longFlag ? `${shortFlag}, ${longFlag}` : shortFlag || longFlag; - const required = flag.isRequired ? "Required: " : ""; - preview += `${indent} ${flagStr.padEnd(18)} ${required}${flag.description ?? ""}\n`; - }); - } - - if (options.length > 0) { - preview += `${indent} Options:\n`; - options.forEach((option) => { - const shortFlag = option.shortFlag ? `${option.shortFlag}` : ""; - const longFlag = option.longFlag ? `${option.longFlag}` : ""; - const flagStr = - shortFlag && longFlag ? `${shortFlag}, ${longFlag}` : shortFlag || longFlag; - const valueType = option.dataType.includes("array") - ? `` - : `<${option.dataType}>`; - const required = option.isRequired ? "Required: " : ""; - preview += `${indent} ${flagStr.padEnd(18)} ${required}${option.description ?? ""}\n`; - preview += `${indent} ${" ".repeat(18)} Value: ${valueType}\n`; - }); - } - - if (arguments_.length > 0) { - preview += `${indent} Arguments:\n`; - arguments_.forEach((arg) => { - const required = arg.isRequired ? "Required: " : ""; - preview += `${indent} ${arg.name.padEnd(18)} ${required}${arg.description ?? ""}\n`; - if (arg.dataType === "Enum") { - preview += `${indent} ${" ".repeat(18)} Values: ${arg.enum?.values?.map((e) => e.value).join(", ")}\n`; - } - }); - } - - const subcommands = tool.commands.filter((cmd) => cmd.parentCommandKey === command.key); - if (subcommands.length > 0) { - preview += `${indent} Subcommands:\n`; - subcommands.forEach((subcmd) => { - printCommand(subcmd, level + 2); - }); - } - }; - - rootCommands.forEach((cmd) => printCommand(cmd)); - } - - return preview; - }; - return (
-        {generateToolPreview()}
+        {generateToolPreview(tool)}
       
diff --git a/src/components/tool-editor/parameter-list.tsx b/src/components/tool-editor/parameter-list.tsx index 35067fd..4e6289c 100644 --- a/src/components/tool-editor/parameter-list.tsx +++ b/src/components/tool-editor/parameter-list.tsx @@ -1,6 +1,5 @@ import { useToolBuilder } from "./tool-editor.context"; import { ExclusionGroup, ParameterType } from "@/components/commandly/types/flat"; -import { createNewParameter } from "@/components/commandly/utils/flat"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -127,7 +126,15 @@ export function ParameterList({ {title} ({parameters.length})
@@ -102,20 +102,20 @@ export function PreviewTabs({ onSaveCommand, streamingTool, isAIGenerating }: Pr
- - - - Generated Command - - - - - + + + + + + + + +
diff --git a/src/components/tool-editor/prompt.ts b/src/components/tool-editor/prompt.ts index f5f6240..f0eabf5 100644 --- a/src/components/tool-editor/prompt.ts +++ b/src/components/tool-editor/prompt.ts @@ -1,3 +1,10 @@ +import { + SORTING_RULES, + GROUPING_RULES, + TYPE_FIX_RULES, + VALIDATION_RULES, +} from "@/components/ai-chat/tool-rules"; + export const generatePrompt = ( jsonSchema: string, options?: { @@ -12,7 +19,7 @@ export const generatePrompt = ( const selectedParameters = options?.context?.selectedParameters ?? []; const hasFocusedContext = selectedCommands.length > 0 || selectedParameters.length > 0; const focusedContextBlock = hasFocusedContext - ? `\n\nIMPORTANT: Focus your changes EXCLUSIVELY on the items listed below. Do not modify any other commands or parameters — preserve them exactly as-is.\n${ + ? `\n\nIMPORTANT: Focus your changes EXCLUSIVELY on the items listed below. Do not modify any other commands or parameters - preserve them exactly as-is.\n${ selectedCommands.length > 0 ? `Commands:\n${selectedCommands.map((c) => ` - ${c.name} (key: ${c.key})`).join("\n")}\n` : "" @@ -61,29 +68,42 @@ ${jsonSchema} 19. **Short Flags:** If short flag is not present then do not add it to the parameter object. 20. Ensure all enum values are correctly parsed and included in the output JSON. Enum values must use the shape \`"enum": { "values": [...], "allowMultiple": false, "separator": "," }\` where \`allowMultiple\` and \`separator\` are optional (default false and "," respectively). 21. Ensure all dependencies and validations are correctly parsed and included in the output JSON. -22. Do not add a \`defaultValue\` field to parameters — this property does not exist in the schema. -23. Tool description and version must be nested under an \`info\` object: \`{ "info": { "description": "...", "version": "..." } }\`. Do not add top-level \`description\` or \`version\` fields. +22. Tool description and version must be nested under an \`info\` object: \`{ "info": { "description": "...", "version": "..." } }\`. Do not add top-level \`description\` or \`version\` fields. -You can: -1. Read the current tool JSON using \`readTool\` — always call this first before making any edits to understand the current structure. For large tools, use the \`fields\` parameter to read only specific sections (e.g. \`["parameters"]\` or \`["commands"]\`). -2. Parse CLI help text and produce a complete tool JSON from scratch. -3. Modify an existing tool definition incrementally using \`editTool\` — can be called multiple times for separate logical groups of changes. -4. Call \`applyToolDefinition\` exactly once when all edits are complete to present the final changes to the user for approval. This is always the last tool call — do not call any other tool after it. -5. Answer questions about CLI tool structure. -6. Search the web for CLI documentation when needed. For large pages, use \`startOffset\` and \`maxChars\` parameters on \`tavilyExtract\` to read content in chunks. +You have access to the following tools: +1. \`readTool\` - Read the current tool JSON to inspect its structure or verify changes. Always call this first before making any edits. Use the \`jsonPath\` parameter to read only specific sections (e.g. \`$.parameters\`, \`$.commands\`, \`$.info\`). +2. \`editTool\` - Apply a JSON merge patch (RFC 7396) to incrementally edit the tool definition. Can be called multiple times for separate logical groups of changes. +3. \`applyToolDefinition\` - Finalize all edits and present them to the user for approval. Call this exactly once after all editTool calls are complete. This is always the last tool call - do not call any other tool after it. +4. \`tavilySearch\` - Search the web for CLI tool documentation, help text, or related information. Use when you need to find official docs or usage examples. +5. \`tavilyExtract\` - Extract content from one or more web page URLs. + +${SORTING_RULES} + +Grouping guidelines: +${GROUPING_RULES} + + + +${TYPE_FIX_RULES} + + + +${VALIDATION_RULES} + + - Always call \`readTool\` first to inspect the current tool before making any changes. - Use \`editTool\` to apply incremental JSON merge patches (RFC 7396). Only include the fields that changed. When modifying arrays (parameters, commands), include the complete updated array. - You may call \`editTool\` multiple times for separate logical groups of changes. After a batch of edits, call \`readTool\` to verify the result before continuing. - Always include a concise \`summary\` on each \`editTool\` call describing what that specific edit changes. -- When modifying an existing tool, preserve all other fields, keys, and structure exactly as-is — including validations, exclusionGroups, dependencies, enum, tags, and any other existing data. +- When modifying an existing tool, preserve all other fields, keys, and structure exactly as-is - including validations, exclusionGroups, dependencies, enum, tags, and any other existing data. - Do not add empty arrays or objects for optional properties (e.g. do not include \`"validations": []\`, \`"exclusionGroups": []\`, \`"tags": []\`, \`"dependencies": []\`, or \`"enum": { "values": [] }\` unless already present). - After all edits are complete and verified with \`readTool\`, call \`applyToolDefinition\` once with an overall summary of all changes. Do NOT call any other tool after \`applyToolDefinition\`. -- For large help text pages: process in sections — read a chunk using \`startOffset\` + \`maxChars\`, apply the relevant \`editTool\` patch, then continue with the next chunk. Do not try to process everything at once. +- For large help text pages: process in sections - break the text into logical groups and create a separate \`editTool\` patch for each section. Do not try to process everything at once. - If the user asks a question without requesting changes, answer in plain text without calling any tool. - All parameter keys must be unique. They should be meaningful and derived from the parameter name or description. - All descriptions should be in sentence case. diff --git a/src/components/tool-editor/tool-editor.context.tsx b/src/components/tool-editor/tool-editor.context.tsx index 4dc4c69..0e4be54 100644 --- a/src/components/tool-editor/tool-editor.context.tsx +++ b/src/components/tool-editor/tool-editor.context.tsx @@ -5,7 +5,7 @@ import { ExclusionGroup, ParameterValue, } from "@/components/commandly/types/flat"; -import { cleanupTool, getAllSubcommands, slugify } from "@/components/commandly/utils/flat"; +import { getAllSubcommands, slugify, fixTool } from "@/components/commandly/utils/flat"; import { createContext, useContext, @@ -60,7 +60,7 @@ type Action = | { type: "REORDER_PARAMETERS"; payload: { parameterKeys: string[] } }; function getDefaultState(tool: Tool): ToolBuilderState { - const cleanTool = cleanupTool(tool); + const cleanTool = fixTool(tool); return { tool: cleanTool, originalTool: cleanTool, @@ -83,7 +83,7 @@ function toolBuilderReducer(state: ToolBuilderState, action: Action): ToolBuilde return getDefaultState(action.payload); case "UPDATE_TOOL": - return { ...state, tool: cleanupTool({ ...state.tool, ...action.payload }) }; + return { ...state, tool: fixTool({ ...state.tool, ...action.payload }) }; case "ADD_SUBCOMMAND": { return { diff --git a/src/components/tool-editor/tool-editor.tsx b/src/components/tool-editor/tool-editor.tsx index 97e8e87..1aa0224 100644 --- a/src/components/tool-editor/tool-editor.tsx +++ b/src/components/tool-editor/tool-editor.tsx @@ -8,6 +8,7 @@ import { ParameterList } from "./parameter-list"; import { PreviewTabs } from "./preview-tabs"; import { ToolBuilderProvider, useToolBuilder } from "./tool-editor.context"; import { Tool } from "@/components/commandly/types/flat"; +import { fixTool } from "@/components/commandly/utils/flat"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { SavedCommand } from "@/lib/types"; @@ -119,7 +120,7 @@ function ToolEditorContent({ })(); const handleContribute = async () => { - const json = JSON.stringify(tool, null, 2); + const json = JSON.stringify(fixTool(tool, { removeMetadata: true }), null, 2); const filePath = `public/tools-collection/${tool.binaryName}.json`; if (isNewTool) { @@ -239,7 +240,7 @@ function ToolEditorContent({
-
+
diff --git a/src/components/tool-editor/tools.ts b/src/components/tool-editor/tools.ts index 22d198d..47689c9 100644 --- a/src/components/tool-editor/tools.ts +++ b/src/components/tool-editor/tools.ts @@ -1,5 +1,6 @@ import { Tool } from "@/components/commandly/types/flat"; -import { cleanupTool, exportToStructuredJSON } from "@/components/commandly/utils/flat"; +import { exportToStructuredJSON } from "@/components/commandly/utils/flat"; +import { fixTool } from "@/components/commandly/utils/flat"; import { tool } from "ai"; import { JSONPath } from "jsonpath-plus"; import { z } from "zod"; @@ -11,7 +12,7 @@ export function applyMergePatch(base: Tool, patch: Partial): Tool { delete (merged as Record)[k]; } } - return cleanupTool(merged); + return fixTool(merged); } export function createEditTool(getBase: () => Tool, onPreview: (tool: Tool) => void) { @@ -23,7 +24,7 @@ export function createEditTool(getBase: () => Tool, onPreview: (tool: Tool) => v patch: z .record(z.string(), z.any()) .describe( - "Partial merge patch — only include top-level fields being changed. Arrays (parameters, commands) must be included in full when modified.", + "Partial merge patch - only include top-level fields being changed. Arrays (parameters, commands) must be included in full when modified.", ), }), execute: async ({ summary, patch }) => { @@ -35,7 +36,7 @@ export function createEditTool(getBase: () => Tool, onPreview: (tool: Tool) => v }); } -export function createApplyToolDefinitionTool(onApplied: () => void, onApply: () => void) { +export function createApplyToolDefinitionTool(onApply: () => void) { return tool({ description: "Finalize all edits and present them to the user for approval. Call this exactly once after all editTool calls are complete. This is always the last tool call.", @@ -44,7 +45,6 @@ export function createApplyToolDefinitionTool(onApplied: () => void, onApply: () }), needsApproval: true, execute: async () => { - onApplied(); onApply(); return { success: true }; }, @@ -100,24 +100,11 @@ export function createTavilySearchTool(apiKey: string) { export function createTavilyExtractTool(apiKey: string) { return tool({ - description: - "Extract content from one or more web page URLs. For large pages, use startOffset and maxChars to read in chunks — call again with the next startOffset when hasMore is true.", + description: "Extract content from one or more web page URLs.", inputSchema: z.object({ urls: z.array(z.string()).describe("URLs to extract content from"), - startOffset: z - .number() - .optional() - .describe( - "Character offset to start reading from (default 0). Use nextOffset from a previous response to continue.", - ), - maxChars: z - .number() - .optional() - .describe( - "Max characters to return per URL (default 6000). Reduce if content is too large to process at once.", - ), }), - execute: async ({ urls, startOffset = 0, maxChars = 6000 }) => { + execute: async ({ urls }) => { const resp = await fetch("https://api.tavily.com/extract", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -131,10 +118,7 @@ export function createTavilyExtractTool(apiKey: string) { results: data.results?.map((r) => ({ url: r.url, - raw_content: r.raw_content.slice(startOffset, startOffset + maxChars), - totalChars: r.raw_content.length, - hasMore: r.raw_content.length > startOffset + maxChars, - nextOffset: startOffset + maxChars, + raw_content: r.raw_content, })) ?? [], }; }, diff --git a/src/components/ui/file-tree.tsx b/src/components/ui/file-tree.tsx index 22bcee8..1704701 100644 --- a/src/components/ui/file-tree.tsx +++ b/src/components/ui/file-tree.tsx @@ -307,6 +307,7 @@ type FolderProps = { isSelectable?: boolean isSelect?: boolean actions?: React.ReactNode + hasChildren?: boolean onClick?: (e: React.MouseEvent) => void } & React.ComponentPropsWithoutRef @@ -322,6 +323,7 @@ const Folder = forwardRef< isSelectable = true, isSelect, actions, + hasChildren = true, onClick: onClickProp, children, ...props @@ -370,17 +372,19 @@ const Folder = forwardRef< } }} > - { - e.stopPropagation() - handleExpand(value) - }} - > - - + {hasChildren && ( + { + e.stopPropagation() + handleExpand(value) + }} + > + + + )} {expandedItems?.includes(value) ? (openIcon ?? ) : (closeIcon ?? )} @@ -393,17 +397,19 @@ const Folder = forwardRef<
- - {element && indicator && + {hasChildren && ( + + {element && indicator && + )} ) } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ce3a3ac..1e02a94 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -14,7 +14,6 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } - export function replaceKey(tool: Tool): Tool { // Deep clone the tool to avoid mutating the original const clone = JSON.parse(JSON.stringify(tool)); @@ -150,25 +149,3 @@ export function replaceKey(tool: Tool): Tool { const finalClone = JSON.parse(JSON.stringify(clone)); return finalClone; } - -export const defaultTool = (toolName?: string, displayName?: string): Tool => { - const finalToolName = toolName || "my-tool"; - return { - binaryName: finalToolName, - displayName: displayName || "My Tool", - commands: [], - parameters: [ - { - key: "--help", - name: "Help", - description: "Displays help menu of tool", - parameterType: "Flag", - dataType: "String", - isRequired: false, - shortFlag: "-h", - longFlag: "--help", - isRepeatable: false, - }, - ], - }; -}; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 658c756..4bad0aa 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -67,7 +67,6 @@ function RootComponent() { @@ -213,7 +212,7 @@ function Navbar() { function RootDocument({ children }: { children: React.ReactNode }) { return ( - + diff --git a/src/routes/docs/__collection__/generated-command.mdx b/src/routes/docs/__collection__/generated-command.mdx index f109c26..729acd9 100644 --- a/src/routes/docs/__collection__/generated-command.mdx +++ b/src/routes/docs/__collection__/generated-command.mdx @@ -41,6 +41,7 @@ Displays a real-time generated CLI command string with copy and save functionali ```tsx import { GeneratedCommand } from "@/components/commandly/generated-command"; +import { Card, CardContent } from "@/components/ui/card"; ``` ### Basic Usage @@ -65,25 +66,33 @@ const tool: Tool = { const [values, setValues] = useState({}) - console.log(cmd)} -/> + + console.log(cmd)} + > + + + + + + + + + ``` -### With Save Handler +### Output Only ```tsx { - navigator.clipboard.writeText(cmd); - toast.success("Command saved!"); - }} /> + + ``` ## API Reference @@ -92,10 +101,23 @@ const [values, setValues] = useState({}) | Prop | Type | Default | Description | | ----------------- | -------------------------------- | ------------------ | ----------------------------------------------------------------------- | -| `tool` | `Tool` | — | The tool definition including `binaryName`, commands, and parameters | +| `tool` | `Tool` | - | The tool definition including `binaryName`, commands, and parameters | | `selectedCommand` | `Command \| null` | `tool.commands[0]` | The currently selected command, or `null` to render the root invocation | -| `parameterValues` | `Record` | — | Map of parameter key to current value | -| `onSaveCommand` | `(command: string) => void` | — | Optional callback fired when the save button is clicked | +| `parameterValues` | `Record` | - | Map of parameter key to current value | +| `onSaveCommand` | `(command: string) => void` | - | Optional callback fired when the save button is clicked | +| `useLongFlag` | `boolean` | `false` | Initial flag preference for generated flags and options | +| `children` | `ReactNode` | - | Optional composed layout using the exported subcomponents | + +### Compound Components + +| Component | Description | +| --------------------------------- | ------------------------------------------------------------------------ | +| `GeneratedCommand.Header` | Renders the `Generated Command` header row | +| `GeneratedCommand.FlagPreference` | Renders the inline long-flag switch when both short and long flags exist | +| `GeneratedCommand.Toolbar` | Optional layout wrapper for custom controls inside the command body | +| `GeneratedCommand.Output` | Renders the scrollable generated command text | +| `GeneratedCommand.Actions` | Renders the copy and save buttons | +| `GeneratedCommand.EmptyState` | Renders the empty state when no command can be produced | ## Notes @@ -105,3 +127,4 @@ const [values, setValues] = useState({}) - Argument parameters are appended in order of their `position` field - Global parameters are included regardless of the selected command - Passing `selectedCommand={null}` includes root-level parameters and skips command path segments +- `GeneratedCommand.Header` and `GeneratedCommand.FlagPreference` must be rendered inside the `GeneratedCommand` root to access shared state diff --git a/src/routes/docs/__collection__/json-output.mdx b/src/routes/docs/__collection__/json-output.mdx index 21ef99d..708845c 100644 --- a/src/routes/docs/__collection__/json-output.mdx +++ b/src/routes/docs/__collection__/json-output.mdx @@ -80,7 +80,7 @@ The output format (flat or nested) is synced to the URL query string via the `ou | Prop | Type | Default | Description | | ------ | ------ | ------- | ---------------------------------------- | -| `tool` | `Tool` | — | The tool definition to serialize as JSON | +| `tool` | `Tool` | - | The tool definition to serialize as JSON | ## Notes diff --git a/src/routes/docs/__collection__/specification-intro.mdx b/src/routes/docs/__collection__/specification-intro.mdx index 1d07848..a24f416 100644 --- a/src/routes/docs/__collection__/specification-intro.mdx +++ b/src/routes/docs/__collection__/specification-intro.mdx @@ -11,9 +11,9 @@ The Commandly specification defines a standard, language-agnostic JSON format fo Two formats are available: -**Flat** — Commands and parameters are stored as flat arrays. Each parameter references its parent command via a `commandKey` field. This is the primary format used by the Commandly editor, renderer components, and the tools collection. +**Flat** - Commands and parameters are stored as flat arrays. Each parameter references its parent command via a `commandKey` field. This is the primary format used by the Commandly editor, renderer components, and the tools collection. -**Nested** — Parameters and subcommands are embedded directly inside each command object. More human-readable and mirrors how most CLI tools present their help output. Useful for authoring tools by hand. +**Nested** - Parameters and subcommands are embedded directly inside each command object. More human-readable and mirrors how most CLI tools present their help output. Useful for authoring tools by hand. See [Flat Schema](/docs/specification-schema) and [Nested Schema](/docs/specification-nested) for the full object reference. diff --git a/src/routes/docs/__collection__/specification-nested.mdx b/src/routes/docs/__collection__/specification-nested.mdx index 9de0050..6166fc5 100644 --- a/src/routes/docs/__collection__/specification-nested.mdx +++ b/src/routes/docs/__collection__/specification-nested.mdx @@ -2,7 +2,7 @@ The nested format embeds parameters and subcommands directly inside each command object. This mirrors how most CLI tools present their help output and is more natural to author by hand. -Unlike the [Flat Schema](/docs/specification-schema), there are no `key` or `commandKey` fields — the hierarchy is expressed via nesting. Exclusion groups reference parameters by `name` instead of `key`. +Unlike the [Flat Schema](/docs/specification-schema), there are no `key` or `commandKey` fields - the hierarchy is expressed via nesting. Exclusion groups reference parameters by `name` instead of `key`. ## Document Object @@ -10,27 +10,27 @@ Unlike the [Flat Schema](/docs/specification-schema), there are no `key` or `com | ------------------ | ------------------------------------- | -------- | ------------------------------------------------------------------------------------ | | `binaryName` | string | ✓ | Unique CLI binary name for the tool. | | `displayName` | string | ✓ | Human-readable display name. | -| `interactive` | boolean | — | If `true`, invoking the root tool opens an interactive session or prompt. | -| `info` | ToolInfo Object | — | Metadata about the tool. See [ToolInfo](/docs/specification-schema#toolinfo-object). | -| `url` | string | — | URL to the tool's homepage or repository. | +| `interactive` | boolean | - | If `true`, invoking the root tool opens an interactive session or prompt. | +| `info` | ToolInfo Object | - | Metadata about the tool. See [ToolInfo](/docs/specification-schema#toolinfo-object). | +| `url` | string | - | URL to the tool's homepage or repository. | | `rootParameters` | NestedParameter Object[] | ✓ | Parameters available on the root invocation when no command is selected. | | `globalParameters` | NestedParameter Object[] | ✓ | Parameters that apply across all commands. | | `commands` | NestedCommand Object[] | ✓ | List of commands with embedded parameters and subcommands. | -| `exclusionGroups` | NestedExclusionGroup Object[] \| null | — | Groups of mutually exclusive or required parameters. | -| `metadata` | ToolMetadata Object | — | Custom metadata. | -| `$schema` | string | — | URL to the JSON Schema for editor validation. | +| `exclusionGroups` | NestedExclusionGroup Object[] \| null | - | Groups of mutually exclusive or required parameters. | +| `metadata` | ToolMetadata Object | - | Custom metadata. | +| `$schema` | string | - | URL to the JSON Schema for editor validation. | ## NestedCommand Object | Field | Type | Required | Description | | ----------------- | ----------------------------- | -------- | ------------------------------------------------------- | | `name` | string | ✓ | Command name as it appears in the CLI invocation. | -| `description` | string | — | Human-readable description of the command. | -| `interactive` | boolean | — | If `true`, the command requires interactive user input. | -| `sortOrder` | number | — | Display sort order. Defaults to `0`. | +| `description` | string | - | Human-readable description of the command. | +| `interactive` | boolean | - | If `true`, the command requires interactive user input. | +| `sortOrder` | number | - | Display sort order. Defaults to `0`. | | `parameters` | NestedParameter Object[] | ✓ | Parameters belonging to this command. | | `subcommands` | NestedCommand Object[] | ✓ | Nested subcommands. Use an empty array if none. | -| `exclusionGroups` | NestedExclusionGroup Object[] | — | Exclusion groups scoped to this command. | +| `exclusionGroups` | NestedExclusionGroup Object[] | - | Exclusion groups scoped to this command. | ## NestedParameter Object @@ -41,21 +41,21 @@ Identical to the flat [Parameter Object](/docs/specification-schema#parameter-ob | `name` | string | ✓ | Human-readable parameter name. Also used as the identifier in exclusion groups. | | `parameterType` | ParameterType | ✓ | One of `Flag`, `Option`, or `Argument`. | | `dataType` | ParameterDataType | ✓ | One of `String`, `Number`, `Boolean`, or `Enum`. | -| `isRequired` | boolean | — | Whether the parameter must be provided. | -| `isRepeatable` | boolean | — | Whether the parameter can appear more than once. | -| `isGlobal` | boolean | — | If `true`, the parameter applies to all commands. | -| `description` | string | — | Human-readable description. | -| `group` | string | — | Display group label. | -| `shortFlag` | string | — | Short flag form (e.g. `-o`). | -| `longFlag` | string | — | Long flag form (e.g. `--output`). | -| `position` | number | — | Zero-based positional index. Required for `Argument` type. | -| `sortOrder` | number | — | Display sort order. | -| `arraySeparator` | string | — | Separator for repeatable parameters joined into a single token. | -| `keyValueSeparator` | string | — | Character between the flag and its value. Defaults to a space. | -| `enum` | ParameterEnumValues Object | — | Required when `dataType` is `Enum`. See [ParameterEnumValues](/docs/specification-schema#parameterenumvalues-object). | -| `validations` | NestedParameterValidation Object[] | — | Validation rules. | -| `dependencies` | NestedParameterDependency Object[] | — | Conditional relationships with other parameters. | -| `metadata` | ParameterMetadata Object | — | Custom metadata. See [ParameterMetadata](/docs/specification-schema#parametermetadata-object). | +| `isRequired` | boolean | - | Whether the parameter must be provided. | +| `isRepeatable` | boolean | - | Whether the parameter can appear more than once. | +| `isGlobal` | boolean | - | If `true`, the parameter applies to all commands. | +| `description` | string | - | Human-readable description. | +| `group` | string | - | Display group label. | +| `shortFlag` | string | - | Short flag form (e.g. `-o`). | +| `longFlag` | string | - | Long flag form (e.g. `--output`). | +| `position` | number | - | Zero-based positional index. Required for `Argument` type. | +| `sortOrder` | number | - | Display sort order. | +| `arraySeparator` | string | - | Separator for repeatable parameters joined into a single token. | +| `keyValueSeparator` | string | - | Character between the flag and its value. Defaults to a space. | +| `enum` | ParameterEnumValues Object | - | Required when `dataType` is `Enum`. See [ParameterEnumValues](/docs/specification-schema#parameterenumvalues-object). | +| `validations` | NestedParameterValidation Object[] | - | Validation rules. | +| `dependencies` | NestedParameterDependency Object[] | - | Conditional relationships with other parameters. | +| `metadata` | ParameterMetadata Object | - | Custom metadata. See [ParameterMetadata](/docs/specification-schema#parametermetadata-object). | ## NestedParameterValidation Object @@ -75,7 +75,7 @@ Equivalent to [ParameterDependency](/docs/specification-schema#parameterdependen | -------------------- | ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | | `dependsOnParameter` | string | ✓ | Name of the parameter being depended upon. | | `dependencyType` | ParameterDependencyType | ✓ | One of `requires` or `conflicts_with`. See [ParameterDependencyType](/docs/specification-schema#parameterdependencytype). | -| `conditionValue` | string | — | Specific value of `dependsOnParameter` that activates this dependency. | +| `conditionValue` | string | - | Specific value of `dependsOnParameter` that activates this dependency. | ## NestedExclusionGroup Object diff --git a/src/routes/docs/__collection__/specification-schema.mdx b/src/routes/docs/__collection__/specification-schema.mdx index 4524656..96c8d56 100644 --- a/src/routes/docs/__collection__/specification-schema.mdx +++ b/src/routes/docs/__collection__/specification-schema.mdx @@ -10,20 +10,20 @@ The root object of a Commandly flat description. | ----------------- | ----------------------- | -------- | -------------------------------------------------------------------------- | | `binaryName` | string | ✓ | Unique CLI binary name for the tool, such as `httpx` or `curl`. | | `displayName` | string | ✓ | Human-readable display name. | -| `interactive` | boolean | — | If `true`, invoking the root tool opens an interactive session or prompt. | -| `info` | ToolInfo Object | — | Metadata about the tool, including the homepage URL. | +| `interactive` | boolean | - | If `true`, invoking the root tool opens an interactive session or prompt. | +| `info` | ToolInfo Object | - | Metadata about the tool, including the homepage URL. | | `commands` | Command Object[] | ✓ | List of commands and subcommands. Can be empty for tools without commands. | | `parameters` | Parameter Object[] | ✓ | Flat list of all parameters across root, command, and global scope. | -| `exclusionGroups` | ExclusionGroup Object[] | — | Groups of mutually exclusive or required parameters. | -| `metadata` | ToolMetadata Object | — | Custom metadata. | +| `exclusionGroups` | ExclusionGroup Object[] | - | Groups of mutually exclusive or required parameters. | +| `metadata` | ToolMetadata Object | - | Custom metadata. | ## ToolInfo Object | Field | Type | Required | Description | | ------------- | ------ | -------- | ----------------------------------------- | -| `description` | string | — | Short description of the tool. | -| `version` | string | — | Tool version string. | -| `url` | string | — | URL to the tool's homepage or repository. | +| `description` | string | - | Short description of the tool. | +| `version` | string | - | Tool version string. | +| `url` | string | - | URL to the tool's homepage or repository. | ## Command Object @@ -31,10 +31,10 @@ The root object of a Commandly flat description. | ------------------ | ------- | -------- | --------------------------------------------------------------------------------------- | | `key` | string | ✓ | Unique identifier. Referenced by parameters via `commandKey`. | | `name` | string | ✓ | Command name as it appears in the CLI invocation. | -| `parentCommandKey` | string | — | Key of the parent command. Omit for root-level commands. | -| `description` | string | — | Human-readable description of the command. | -| `interactive` | boolean | — | If `true`, the command requires interactive user input at runtime. Defaults to `false`. | -| `sortOrder` | number | — | Display sort order. | +| `parentCommandKey` | string | - | Key of the parent command. Omit for root-level commands. | +| `description` | string | - | Human-readable description of the command. | +| `interactive` | boolean | - | If `true`, the command requires interactive user input at runtime. Defaults to `false`. | +| `sortOrder` | number | - | Display sort order. | ## Parameter Object @@ -44,22 +44,22 @@ The root object of a Commandly flat description. | `name` | string | ✓ | Human-readable parameter name. | | `parameterType` | ParameterType | ✓ | One of `Flag`, `Option`, or `Argument`. | | `dataType` | ParameterDataType | ✓ | One of `String`, `Number`, `Boolean`, or `Enum`. | -| `isRequired` | boolean | — | Whether the parameter must be provided. | -| `isRepeatable` | boolean | — | Whether the parameter can appear more than once. | -| `isGlobal` | boolean | — | If `true`, the parameter applies to all commands regardless of `commandKey`. | -| `commandKey` | string | — | Key of the command this parameter belongs to. Omit for root-level and global parameters. | -| `description` | string | — | Human-readable description. | -| `group` | string | — | Display group label for UI grouping. | -| `shortFlag` | string | — | Short flag form (e.g. `-o`). Used for `Flag` and `Option` types. | -| `longFlag` | string | — | Long flag form (e.g. `--output`). Used for `Flag` and `Option` types. | -| `position` | number | — | Zero-based positional index. Required for `Argument` type parameters. | -| `sortOrder` | number | — | Display sort order. | -| `arraySeparator` | string | — | Separator used when a repeatable parameter is joined into a single token. | -| `keyValueSeparator` | string | — | Character between the flag and its value. Defaults to a space. | -| `enum` | ParameterEnumValues Object | — | Required when `dataType` is `Enum`. | -| `validations` | ParameterValidation Object[] | — | Validation rules applied to the parameter value. | -| `dependencies` | ParameterDependency Object[] | — | Conditional relationships with other parameters. | -| `metadata` | ParameterMetadata Object | — | Custom metadata. | +| `isRequired` | boolean | - | Whether the parameter must be provided. | +| `isRepeatable` | boolean | - | Whether the parameter can appear more than once. | +| `isGlobal` | boolean | - | If `true`, the parameter applies to all commands regardless of `commandKey`. | +| `commandKey` | string | - | Key of the command this parameter belongs to. Omit for root-level and global parameters. | +| `description` | string | - | Human-readable description. | +| `group` | string | - | Display group label for UI grouping. | +| `shortFlag` | string | - | Short flag form (e.g. `-o`). Used for `Flag` and `Option` types. | +| `longFlag` | string | - | Long flag form (e.g. `--output`). Used for `Flag` and `Option` types. | +| `position` | number | - | Zero-based positional index. Required for `Argument` type parameters. | +| `sortOrder` | number | - | Display sort order. | +| `arraySeparator` | string | - | Separator used when a repeatable parameter is joined into a single token. | +| `keyValueSeparator` | string | - | Character between the flag and its value. Defaults to a space. | +| `enum` | ParameterEnumValues Object | - | Required when `dataType` is `Enum`. | +| `validations` | ParameterValidation Object[] | - | Validation rules applied to the parameter value. | +| `dependencies` | ParameterDependency Object[] | - | Conditional relationships with other parameters. | +| `metadata` | ParameterMetadata Object | - | Custom metadata. | ## ParameterType @@ -83,8 +83,8 @@ The root object of a Commandly flat description. | Field | Type | Required | Description | | --------------- | --------------------------- | -------- | ---------------------------------------------------------------- | | `values` | ParameterEnumValue Object[] | ✓ | Ordered list of allowed values. | -| `allowMultiple` | boolean | — | Whether multiple values can be selected simultaneously. | -| `separator` | string | — | Separator used when multiple values are combined into one token. | +| `allowMultiple` | boolean | - | Whether multiple values can be selected simultaneously. | +| `separator` | string | - | Separator used when multiple values are combined into one token. | ## ParameterEnumValue Object @@ -92,9 +92,9 @@ The root object of a Commandly flat description. | ------------- | ------- | -------- | ------------------------------------------ | | `value` | string | ✓ | The raw value passed to the CLI. | | `displayName` | string | ✓ | Human-readable label shown in the UI. | -| `description` | string | — | Description of what this value does. | -| `isDefault` | boolean | — | Whether this value is selected by default. | -| `sortOrder` | number | — | Display sort order. | +| `description` | string | - | Description of what this value does. | +| `isDefault` | boolean | - | Whether this value is selected by default. | +| `sortOrder` | number | - | Display sort order. | ## ExclusionGroup Object @@ -102,9 +102,9 @@ Defines a group of parameters that are mutually exclusive or where exactly one m | Field | Type | Required | Description | | --------------- | ------------- | -------- | ----------------------------------------------- | -| `key` | string | — | Unique identifier for the group. | +| `key` | string | - | Unique identifier for the group. | | `name` | string | ✓ | Human-readable group name. | -| `commandKey` | string | — | Key of the command this group applies to. | +| `commandKey` | string | - | Key of the command this group applies to. | | `exclusionType` | ExclusionType | ✓ | One of `mutual_exclusive` or `required_one_of`. | | `parameterKeys` | string[] | ✓ | Keys of the parameters belonging to this group. | @@ -144,7 +144,7 @@ Defines a conditional relationship between two parameters. | `parameterKey` | string | ✓ | Key of the parameter this dependency rule applies to. | | `dependsOnParameterKey` | string | ✓ | Key of the parameter being depended upon. | | `dependencyType` | ParameterDependencyType | ✓ | One of `requires` or `conflicts_with`. | -| `conditionValue` | string | — | Specific value of `dependsOnParameter` that activates this dependency. If omitted, the dependency applies whenever `dependsOnParameter` is set. | +| `conditionValue` | string | - | Specific value of `dependsOnParameter` that activates this dependency. If omitted, the dependency applies whenever `dependsOnParameter` is set. | ## ParameterDependencyType @@ -157,4 +157,4 @@ Defines a conditional relationship between two parameters. | Field | Type | Required | Description | | ------ | -------- | -------- | --------------------------------------------------------------------------------------------- | -| `tags` | string[] | — | Custom string tags. Used to annotate parameters (e.g. `"non-configurable"`, `"output-file"`). | +| `tags` | string[] | - | Custom string tags. Used to annotate parameters (e.g. `"non-configurable"`, `"output-file"`). | diff --git a/src/routes/docs/__collection__/tool-renderer.mdx b/src/routes/docs/__collection__/tool-renderer.mdx index 0f3cc81..cdf2840 100644 --- a/src/routes/docs/__collection__/tool-renderer.mdx +++ b/src/routes/docs/__collection__/tool-renderer.mdx @@ -90,10 +90,10 @@ const customCatalog: ParameterRendererEntry[] = [ | Prop | Type | Default | Description | | ---------------------- | ---------------------------------------------- | --------------------- | --------------------------------------------------------------- | -| `tool` | `Tool` | — | The tool definition to render inputs for | +| `tool` | `Tool` | - | The tool definition to render inputs for | | `selectedCommand` | `Command` | Auto-detected default | The command whose parameters should be shown | -| `parameterValues` | `Record` | — | Current values keyed by parameter key | -| `updateParameterValue` | `(key: string, value: ParameterValue) => void` | — | Callback to update a parameter's value | +| `parameterValues` | `Record` | - | Current values keyed by parameter key | +| `updateParameterValue` | `(key: string, value: ParameterValue) => void` | - | Callback to update a parameter's value | | `catalog` | `ParameterRendererEntry[]` | `defaultComponents()` | Ordered list of renderer entries; first matching condition wins | ### ParameterRendererEntry diff --git a/src/routes/docs/__collection__/ui.mdx b/src/routes/docs/__collection__/ui.mdx index 3bfe762..dd87e67 100644 --- a/src/routes/docs/__collection__/ui.mdx +++ b/src/routes/docs/__collection__/ui.mdx @@ -54,6 +54,7 @@ import { GeneratedCommand } from "@/components/commandly/generated-command"; import { JsonOutput } from "@/components/commandly/json-output"; import { ToolRenderer } from "@/components/commandly/tool-renderer"; import type { Tool, Parameter, ParameterValue } from "@/components/commandly/lib/types/flat"; +import { Card, CardContent } from "@/components/ui/card"; ``` ### Using All Three Together @@ -70,7 +71,17 @@ const [values, setValues] = useState>({}) } />
- + + + + + + + + + + +
@@ -81,3 +92,4 @@ const [values, setValues] = useState>({}) - All three components share the same `Tool` type from `lib/types/flat.ts` - The `lib/utils/` helpers handle conversion between flat and nested structures - Individual components (`generated-command`, `json-output`, `tool-renderer`) can also be installed separately +- `GeneratedCommand` can be used as a composed card layout or as a minimal output-only renderer diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 35d1f9d..453a881 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,11 +1,6 @@ import { ToolRenderer } from "@/components/commandly/tool-renderer"; -import type { - Tool, - ParameterType, - Parameter, - ParameterValue, -} from "@/components/commandly/types/flat"; -import { getCommandPath } from "@/components/commandly/utils/flat"; +import type { Tool, ParameterType } from "@/components/commandly/types/flat"; +import { generateCommand } from "@/components/commandly/utils/flat"; import { TextMarquee } from "@/components/text-marquee"; import { ToolBuilderProvider, useToolBuilder } from "@/components/tool-editor/tool-editor.context"; import { Badge } from "@/components/ui/badge"; @@ -402,53 +397,10 @@ function DemoToolEditor() { function CompactGeneratedCommand() { const { tool, selectedCommand, parameterValues } = useToolBuilder(); - const command = useMemo(() => { - let cmd = tool.binaryName; - const sc = selectedCommand ?? tool.commands[0]; - - if (sc) { - const path = getCommandPath(sc, tool); - if (tool.binaryName !== path) cmd = `${tool.binaryName} ${path}`; - } - - const allParams = [ - ...tool.parameters.filter((p) => p.isGlobal), - ...(sc - ? tool.parameters.filter((p) => p.commandKey === sc.key) - : tool.parameters.filter((p) => !p.commandKey && !p.isGlobal)), - ]; - - const positional: { param: Parameter; value: ParameterValue }[] = []; - - allParams.forEach((param) => { - const value = parameterValues[param.key]; - if (value === undefined || value === "" || value === false) return; - - if (param.parameterType === "Argument") { - positional.push({ param, value }); - return; - } - - if (param.parameterType === "Flag" && value === true) { - const flag = param.shortFlag || param.longFlag; - if (flag) cmd += ` ${flag}`; - } else if (param.parameterType === "Option") { - const flag = param.shortFlag || param.longFlag; - if (flag) { - const sep = param.keyValueSeparator ?? " "; - cmd += ` ${flag}${sep}${Array.isArray(value) ? value.join(" ") : value}`; - } - } - }); - - positional - .sort((a, b) => (a.param.position || 0) - (b.param.position || 0)) - .forEach(({ value }) => { - if (!Array.isArray(value)) cmd += ` ${value}`; - }); - - return cmd; - }, [tool, selectedCommand, parameterValues]); + const command = useMemo( + () => generateCommand(tool, parameterValues, { selectedCommand, useLongFlag: true }), + [tool, selectedCommand, parameterValues], + ); return (
diff --git a/src/routes/tools/$toolName/index.tsx b/src/routes/tools/$toolName/index.tsx index 5391f28..96079b3 100644 --- a/src/routes/tools/$toolName/index.tsx +++ b/src/routes/tools/$toolName/index.tsx @@ -22,16 +22,9 @@ import { removeSavedCommandFromStorage, } from "@/lib/editor-utils"; import { SavedCommand } from "@/lib/types"; -import { cn, defaultTool } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { - CheckIcon, - ChevronsUpDownIcon, - Edit2Icon, - InfoIcon, - SaveIcon, - TerminalIcon, -} from "lucide-react"; +import { CheckIcon, ChevronsUpDownIcon, Edit2Icon, InfoIcon, SaveIcon } from "lucide-react"; import { useQueryState } from "nuqs"; import { useState } from "react"; import { toast } from "sonner"; @@ -39,18 +32,23 @@ import { toast } from "sonner"; export const Route = createFileRoute("/tools/$toolName/")({ component: RouteComponent, validateSearch: (search) => ({ - newTool: typeof search.newTool === "string" ? search.newTool : undefined, + isLocal: search.isLocal === true, }), - loaderDeps: ({ search: { newTool } }) => ({ - newTool, + loaderDeps: ({ search: { isLocal } }) => ({ + isLocal, }), - loader: async ({ params: { toolName }, deps: { newTool } }) => { - if (newTool) { - const newToolData = localStorage.getItem(`tool-${newTool}`); - if (newToolData) { - return JSON.parse(newToolData) as Tool; + loader: async ({ params: { toolName }, deps: { isLocal } }) => { + if (isLocal) { + const localData = localStorage.getItem(`tool-${toolName}`); + if (localData) { + return JSON.parse(localData) as Tool; } else { - return defaultTool() as Tool; + return { + binaryName: toolName, + displayName: toolName, + commands: [], + parameters: [], + } as Tool; } } else { return await fetchToolDetails(toolName); @@ -68,7 +66,7 @@ export const Route = createFileRoute("/tools/$toolName/")({ function RouteComponent() { const tool = Route.useLoaderData(); - const { newTool } = Route.useSearch(); + const { isLocal } = Route.useSearch(); const [parameterValues, setParameterValues] = useState({}); const [savedCommands, setSavedCommands] = useState(() => { @@ -163,7 +161,7 @@ function RouteComponent() { Edit @@ -299,24 +297,24 @@ function RouteComponent() { - - - - Generated Command - - - - command.name === selectedCommand) - } - tool={tool} - parameterValues={parameterValues} - onSaveCommand={handleSaveCommand} - /> - + command.name === selectedCommand) + } + tool={tool} + parameterValues={parameterValues} + onSaveCommand={handleSaveCommand} + > + + + + + + + +
{ setNewToolName(""); setNewToolDisplayName(""); + setNewToolDescription(""); + setNewToolUrl(""); setDisplayNameEdited(false); setNewToolDialogOpen(true); }; @@ -95,7 +99,17 @@ function RouteComponent() { const handleCreateTool = () => { const name = slugify(newToolName.trim()); const displayName = newToolDisplayName.trim() || newToolName.trim(); - const newTool: Tool = { binaryName: name, displayName, commands: [], parameters: [] }; + const description = newToolDescription.trim(); + const url = newToolUrl.trim(); + const info = + description || url ? { ...(description && { description }), ...(url && { url }) } : undefined; + const newTool: Tool = { + binaryName: name, + displayName, + commands: [], + parameters: [], + ...(info && { info }), + }; localStorage.setItem(`tool-${name}`, JSON.stringify(newTool)); setNewToolDialogOpen(false); navigation({ @@ -181,6 +195,24 @@ function RouteComponent() { }} /> +
+ + setNewToolDescription(e.target.value)} + placeholder="Optional" + /> +
+
+ + setNewToolUrl(e.target.value)} + placeholder="https://example.com (optional)" + /> +