diff --git a/README.md b/README.md index 34c5170..f75261e 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,64 @@ Or generate a `.d.ts` from the parsed schema: npx cli-to-js git --dts --subcommands -o git.d.ts ``` +## TypeScript plugin (automatic types in your editor) + +If you don't want to pass a generic or check in a `.d.ts`, register the bundled TypeScript language-service plugin. It runs inside `tsserver`, scans your source for `convertCliToJs("")` and `fromHelpText("", ...)` calls, runs each binary's `--help` in the background, and injects real types — zero codegen, zero generic. + +```jsonc +// tsconfig.json +{ + "compilerOptions": { + "plugins": [{ "name": "cli-to-js/plugin" }], + }, +} +``` + +```ts +import { convertCliToJs } from "cli-to-js"; + +const git = await convertCliToJs("git"); // ← plugin augments the return type +await git.commit({ message: "hi" }); // ← flags autocomplete, unknown keys error +``` + +VS Code picks this up automatically. Other editors may need to be told to use the workspace TypeScript version. + +### Configuration + +```jsonc +{ + "plugins": [ + { + "name": "cli-to-js/plugin", + "disabled": false, // kill switch + "timeout": 3000, // ms to wait for ` --help` + "helpFlag": "--help", // override per workspace if needed + "allowList": ["git", "claude"], // only resolve these binaries + "denyList": ["rm"], // never resolve these + }, + ], +} +``` + +Set `CLI_TO_JS_PLUGIN_DISABLE=1` in the environment to disable it globally. + +### How it compares + +| | generic | `--dts` codegen | plugin | +| ------------------------- | ----------------- | --------------- | -------------- | +| Editor autocomplete | ✅ (you write it) | ✅ | ✅ (automatic) | +| `tsc` / CI type-checking | ✅ | ✅ | ❌ editor only | +| Manual step | write types | run codegen | none | +| Stays in sync with binary | manual | re-run codegen | automatic | + +The plugin is the best DX during development. For CI builds, still use `--dts` or the generic — `tsc` does not run TypeScript language-service plugins. + +### Limits + +- Only string-literal binary names are resolved: `convertCliToJs("git")` works, `convertCliToJs(binaryFromVar)` falls back to the loose default type. +- First paint shows the loose type for a moment while ` --help` runs in the background, then upgrades. +- The plugin spawns the binaries it finds in your source. Use `allowList`/`denyList` if that matters for your environment. + ## From a help text string If you already have the help text, skip the binary lookup: diff --git a/packages/cli-to-js/package.json b/packages/cli-to-js/package.json index 8d5c00f..1512a9b 100644 --- a/packages/cli-to-js/package.json +++ b/packages/cli-to-js/package.json @@ -29,6 +29,7 @@ }, "files": [ "dist", + "plugin", "README.md" ], "type": "module", @@ -38,6 +39,11 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./plugin": { + "types": "./plugin/index.d.ts", + "require": "./plugin/index.js", + "default": "./plugin/index.js" + }, "./package.json": "./package.json" }, "publishConfig": { @@ -52,6 +58,17 @@ "dependencies": { "commander": "^14.0.3" }, + "devDependencies": { + "typescript": "^6.0.2" + }, + "peerDependencies": { + "typescript": ">=4.9" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, "engines": { "node": ">=18" } diff --git a/packages/cli-to-js/plugin/index.d.ts b/packages/cli-to-js/plugin/index.d.ts new file mode 100644 index 0000000..5b23b1e --- /dev/null +++ b/packages/cli-to-js/plugin/index.d.ts @@ -0,0 +1,3 @@ +import type { server } from "typescript"; +declare const pluginInit: server.PluginModuleFactory; +export = pluginInit; diff --git a/packages/cli-to-js/plugin/index.js b/packages/cli-to-js/plugin/index.js new file mode 100644 index 0000000..f46b8f4 --- /dev/null +++ b/packages/cli-to-js/plugin/index.js @@ -0,0 +1 @@ +module.exports = require("../dist/plugin/index.cjs"); diff --git a/packages/cli-to-js/plugin/package.json b/packages/cli-to-js/plugin/package.json new file mode 100644 index 0000000..e10e315 --- /dev/null +++ b/packages/cli-to-js/plugin/package.json @@ -0,0 +1,5 @@ +{ + "type": "commonjs", + "main": "./index.js", + "types": "./index.d.ts" +} diff --git a/packages/cli-to-js/src/cli-api.ts b/packages/cli-to-js/src/cli-api.ts index 67e6d8c..10291ab 100644 --- a/packages/cli-to-js/src/cli-api.ts +++ b/packages/cli-to-js/src/cli-api.ts @@ -9,17 +9,11 @@ interface CommandPromise extends Promise { } interface SubcommandFn> { - ( - options?: TOptions & { _?: string | string[]; [key: string]: unknown }, - config?: RunConfig, - ): CommandPromise; + (options?: TOptions & { _?: string | string[] }, config?: RunConfig): CommandPromise; } interface SpawnFn> { - ( - options?: TOptions & { _?: string | string[]; [key: string]: unknown }, - config?: RunConfig, - ): CommandProcess; + (options?: TOptions & { _?: string | string[] }, config?: RunConfig): CommandProcess; } interface CliApiBase { diff --git a/packages/cli-to-js/src/constants.ts b/packages/cli-to-js/src/constants.ts index 51b57f3..cb8c7af 100644 --- a/packages/cli-to-js/src/constants.ts +++ b/packages/cli-to-js/src/constants.ts @@ -5,3 +5,5 @@ export const COLUMN_SEPARATOR_MIN_SPACES = 2; export const SHORT_FLAG_MAX_LENGTH = 1; export const DEFAULT_FAILURE_EXIT_CODE = 1; export const MAX_SUGGESTION_DISTANCE = 3; +export const PLUGIN_RESOLVE_TIMEOUT_MS = 3_000; +export const PLUGIN_REGENERATE_DEBOUNCE_MS = 150; diff --git a/packages/cli-to-js/src/index.ts b/packages/cli-to-js/src/index.ts index 5ab24a8..15a5b18 100644 --- a/packages/cli-to-js/src/index.ts +++ b/packages/cli-to-js/src/index.ts @@ -11,7 +11,43 @@ export interface CliToJsOptions { subcommands?: boolean; } -export const convertCliToJs = async < +export interface KnownCliOptions {} + +export interface ConvertCliToJs { + ( + binaryName: N, + options?: CliToJsOptions, + ): Promise< + CliApi< + KnownCliOptions[N] extends Record> + ? KnownCliOptions[N] + : Record> + > + >; + > = Record>>( + binaryName: string, + options?: CliToJsOptions, + ): Promise>; +} + +export interface FromHelpText { + ( + binaryName: N, + helpText: string, + options?: CliToJsOptions, + ): CliApi< + KnownCliOptions[N] extends Record> + ? KnownCliOptions[N] + : Record> + >; + > = Record>>( + binaryName: string, + helpText: string, + options?: CliToJsOptions, + ): CliApi; +} + +export const convertCliToJs: ConvertCliToJs = async < T extends Record> = Record>, >( binaryName: string, @@ -21,7 +57,7 @@ export const convertCliToJs = async < return buildApi(binaryName, schema, { cwd: options.cwd, env: options.env }); }; -export const fromHelpText = < +export const fromHelpText: FromHelpText = < T extends Record> = Record>, >( binaryName: string, diff --git a/packages/cli-to-js/src/plugin/emit.ts b/packages/cli-to-js/src/plugin/emit.ts new file mode 100644 index 0000000..2e595a2 --- /dev/null +++ b/packages/cli-to-js/src/plugin/emit.ts @@ -0,0 +1,70 @@ +import type { CliSchema, ParsedFlag } from "../parse-help-text.js"; +import { kebabToCamel } from "../utils/kebab-to-camel.js"; + +export interface EmitInput { + binaryName: string; + schema: CliSchema | null; + error: string | null; +} + +const escapeStringLiteral = (value: string): string => `"${value.replace(/["\\]/g, "\\$&")}"`; + +const propertyKey = (name: string): string => { + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) return name; + return escapeStringLiteral(name); +}; + +const flagTypeString = (flag: ParsedFlag): string => { + if (!flag.takesValue) return "boolean"; + if (flag.choices && flag.choices.length > 0) { + return flag.choices.map(escapeStringLiteral).join(" | "); + } + return "string"; +}; + +const emitOptionsInline = (flags: ParsedFlag[]): string => { + if (flags.length === 0) return "Record"; + const properties = flags.map((flag) => { + const propertyName = kebabToCamel(flag.longName); + const optionalMarker = flag.isRequired ? "" : "?"; + return `${propertyKey(propertyName)}${optionalMarker}: ${flagTypeString(flag)}`; + }); + return `{ ${properties.join("; ")} }`; +}; + +const emitSubcommandMap = (schema: CliSchema): string => { + if (schema.command.subcommands.length === 0) return "{}"; + const entries = schema.command.subcommands.map((subcommand) => { + const optionsType = emitOptionsInline(subcommand.flags ?? []); + return `${propertyKey(subcommand.name)}: ${optionsType}`; + }); + return `{ ${entries.join("; ")} }`; +}; + +export const emitAugmentation = (inputs: EmitInput[]): string => { + const preamble = [ + "// Generated by cli-to-js/plugin — do not edit.", + "// Refreshes automatically when the editor rescans source files.", + ]; + + const readyInputs = inputs.filter((input) => input.schema !== null); + if (readyInputs.length === 0) { + return `${preamble.join("\n")}\nexport {};\n`; + } + + const mapEntries = readyInputs + .map((input) => { + const shape = emitSubcommandMap(input.schema as CliSchema); + return ` ${propertyKey(input.binaryName)}: ${shape};`; + }) + .join("\n"); + + return `${preamble.join("\n")} +declare module "cli-to-js" { + interface KnownCliOptions { +${mapEntries} + } +} +export {}; +`; +}; diff --git a/packages/cli-to-js/src/plugin/index.ts b/packages/cli-to-js/src/plugin/index.ts new file mode 100644 index 0000000..ea68194 --- /dev/null +++ b/packages/cli-to-js/src/plugin/index.ts @@ -0,0 +1,202 @@ +import * as path from "node:path"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as crypto from "node:crypto"; +import type { server, LanguageService } from "typescript"; +import type { CliSchema } from "../parse-help-text.js"; +import { scanCliCalls } from "./scan.js"; +import { resolveBinarySchema, type BinaryResolution } from "./resolve.js"; +import { emitAugmentation, type EmitInput } from "./emit.js"; +import { PLUGIN_RESOLVE_TIMEOUT_MS, PLUGIN_REGENERATE_DEBOUNCE_MS } from "../constants.js"; + +interface PluginConfig { + disabled?: boolean; + timeout?: number; + helpFlag?: string; + allowList?: string[]; + denyList?: string[]; +} + +interface BinaryCacheEntry extends BinaryResolution { + helpFlag: string; +} + +const SUPPRESS_ENV_VAR = "CLI_TO_JS_PLUGIN_DISABLE"; + +const projectHash = (projectDirectory: string): string => + crypto.createHash("sha1").update(projectDirectory).digest("hex").slice(0, 16); + +const pluginInit: server.PluginModuleFactory = ({ typescript: tsModule }) => { + return { + create(info) { + const rawConfig = (info.config ?? {}) as PluginConfig; + const disabled = Boolean(rawConfig.disabled) || process.env[SUPPRESS_ENV_VAR] === "1"; + if (disabled) { + return info.languageService; + } + + const resolveTimeout = rawConfig.timeout ?? PLUGIN_RESOLVE_TIMEOUT_MS; + const helpFlag = rawConfig.helpFlag ?? "--help"; + const allowList = rawConfig.allowList ? new Set(rawConfig.allowList) : null; + const denyList = rawConfig.denyList ? new Set(rawConfig.denyList) : null; + + const projectDirectory = info.project.getCurrentDirectory(); + const virtualDirectory = path.join( + os.tmpdir(), + "cli-to-js-plugin", + projectHash(projectDirectory), + ); + const virtualFilePath = path.join(virtualDirectory, "augmentations.d.ts"); + + const binaryCache = new Map(); + const inflightResolutions = new Map>(); + let lastWrittenContent = ""; + let lastScanSignature = ""; + let debounceHandle: NodeJS.Timeout | null = null; + + try { + fs.mkdirSync(virtualDirectory, { recursive: true }); + const initialContent = buildCurrentContent(binaryCache); + fs.writeFileSync(virtualFilePath, initialContent); + lastWrittenContent = initialContent; + } catch (initError) { + info.project.projectService.logger.info( + `cli-to-js/plugin could not create augmentations file: ${String(initError)}`, + ); + return info.languageService; + } + + const host = info.languageServiceHost; + const originalGetScriptFileNames = host.getScriptFileNames.bind(host); + host.getScriptFileNames = () => { + const existing = originalGetScriptFileNames(); + if (existing.includes(virtualFilePath)) return existing; + return [...existing, virtualFilePath]; + }; + + const isBinaryPermitted = (binaryName: string): boolean => { + if (denyList?.has(binaryName)) return false; + if (allowList && !allowList.has(binaryName)) return false; + return /^[A-Za-z0-9_.-]+$/.test(binaryName); + }; + + const refreshVirtualFile = (): void => { + const nextContent = buildCurrentContent(binaryCache); + if (nextContent === lastWrittenContent) return; + try { + fs.writeFileSync(virtualFilePath, nextContent); + lastWrittenContent = nextContent; + } catch (writeError) { + info.project.projectService.logger.info( + `cli-to-js/plugin could not update augmentations file: ${String(writeError)}`, + ); + return; + } + info.project.refreshDiagnostics(); + }; + + const beginResolution = (binaryName: string): void => { + if (inflightResolutions.has(binaryName)) return; + binaryCache.set(binaryName, { + status: "pending", + schema: null, + error: null, + resolvedAt: 0, + helpFlag, + }); + const pending = resolveBinarySchema(binaryName, { + timeout: resolveTimeout, + helpFlag, + }) + .then((resolution) => { + binaryCache.set(binaryName, { ...resolution, helpFlag }); + refreshVirtualFile(); + }) + .catch((caught) => { + const message = caught instanceof Error ? caught.message : String(caught); + binaryCache.set(binaryName, { + status: "error", + schema: null, + error: message, + resolvedAt: Date.now(), + helpFlag, + }); + refreshVirtualFile(); + }) + .finally(() => { + inflightResolutions.delete(binaryName); + }); + inflightResolutions.set(binaryName, pending); + }; + + const scanAndQueue = (): void => { + const program = info.languageService.getProgram(); + if (!program) return; + const calls = scanCliCalls(program, tsModule); + const unique = new Set(); + for (const call of calls) { + if (isBinaryPermitted(call.binaryName)) { + unique.add(call.binaryName); + } + } + + const signature = [...unique].sort().join("\u0000"); + if (signature === lastScanSignature) return; + lastScanSignature = signature; + + for (const binaryName of unique) { + if (!binaryCache.has(binaryName)) { + beginResolution(binaryName); + } + } + }; + + const scheduleScan = (): void => { + if (debounceHandle) return; + debounceHandle = setTimeout(() => { + debounceHandle = null; + try { + scanAndQueue(); + } catch (scanError) { + info.project.projectService.logger.info( + `cli-to-js/plugin scan failed: ${String(scanError)}`, + ); + } + }, PLUGIN_REGENERATE_DEBOUNCE_MS); + }; + + scheduleScan(); + + const proxy: LanguageService = Object.create(null); + const methodNames = Object.keys(info.languageService) as Array; + for (const methodName of methodNames) { + const originalMethod = info.languageService[methodName]; + if (typeof originalMethod !== "function") { + (proxy[methodName] as unknown) = originalMethod; + continue; + } + (proxy[methodName] as unknown) = (...callArgs: unknown[]) => { + scheduleScan(); + return (originalMethod as (...innerArgs: unknown[]) => unknown).apply( + info.languageService, + callArgs, + ); + }; + } + + return proxy; + }, + }; +}; + +const buildCurrentContent = ( + binaryCache: ReadonlyMap, +): string => { + const inputs: EmitInput[] = []; + for (const [binaryName, entry] of binaryCache) { + inputs.push({ binaryName, schema: entry.schema, error: entry.error }); + } + return emitAugmentation(inputs); +}; + +export default pluginInit; diff --git a/packages/cli-to-js/src/plugin/resolve.ts b/packages/cli-to-js/src/plugin/resolve.ts new file mode 100644 index 0000000..a20aed1 --- /dev/null +++ b/packages/cli-to-js/src/plugin/resolve.ts @@ -0,0 +1,93 @@ +import { spawn } from "node:child_process"; +import { parseHelpText, type CliSchema } from "../parse-help-text.js"; +import { enrichSubcommands } from "../parse-subcommands.js"; +import { selectHelpOutput } from "../utils/best-help-text.js"; +import { PLUGIN_RESOLVE_TIMEOUT_MS } from "../constants.js"; + +export type ResolveStatus = "pending" | "ready" | "error"; + +export interface BinaryResolution { + status: ResolveStatus; + schema: CliSchema | null; + error: string | null; + resolvedAt: number; +} + +export interface ResolverOptions { + timeout?: number; + helpFlag?: string; +} + +const runHelpOnBinary = ( + binaryName: string, + helpFlag: string, + timeoutMs: number, +): Promise<{ stdout: string; stderr: string; exitCode: number }> => + new Promise((resolve, reject) => { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let spawnError: Error | null = null; + + const child = spawn(binaryName, [helpFlag], { + stdio: "pipe", + windowsHide: true, + timeout: timeoutMs, + }); + + child.stdin?.end(); + child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); + child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + child.on("error", (error) => { + spawnError = error; + }); + child.on("close", (exitCode) => { + if (spawnError) { + reject(spawnError); + return; + } + resolve({ + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + exitCode: exitCode ?? 1, + }); + }); + }); + +export const resolveBinarySchema = async ( + binaryName: string, + options: ResolverOptions = {}, +): Promise => { + const timeoutMs = options.timeout ?? PLUGIN_RESOLVE_TIMEOUT_MS; + const helpFlag = options.helpFlag ?? "--help"; + + try { + const result = await runHelpOnBinary(binaryName, helpFlag, timeoutMs); + const helpText = selectHelpOutput(result.stdout, result.stderr); + if (!helpText.trim()) { + return { + status: "error", + schema: null, + error: `"${binaryName} ${helpFlag}" produced no output`, + resolvedAt: Date.now(), + }; + } + const schema = parseHelpText(binaryName, helpText); + if (schema.command.subcommands.length > 0) { + await enrichSubcommands(binaryName, schema, { timeout: timeoutMs }); + } + return { + status: "ready", + schema, + error: null, + resolvedAt: Date.now(), + }; + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + return { + status: "error", + schema: null, + error: message, + resolvedAt: Date.now(), + }; + } +}; diff --git a/packages/cli-to-js/src/plugin/scan.ts b/packages/cli-to-js/src/plugin/scan.ts new file mode 100644 index 0000000..b0ec83f --- /dev/null +++ b/packages/cli-to-js/src/plugin/scan.ts @@ -0,0 +1,51 @@ +import type { server, Program, Node, LeftHandSideExpression } from "typescript"; + +export interface DiscoveredCall { + binaryName: string; + sourceFile: string; + position: number; + length: number; +} + +export type TsModule = Parameters[0]["typescript"]; + +const TARGET_CALL_NAMES = new Set(["convertCliToJs", "fromHelpText"]); +const NODE_MODULES_SEGMENT = "/node_modules/"; + +const isTargetCallee = (expression: LeftHandSideExpression, tsModule: TsModule): boolean => { + if (tsModule.isIdentifier(expression)) { + return TARGET_CALL_NAMES.has(expression.text); + } + if (tsModule.isPropertyAccessExpression(expression)) { + return TARGET_CALL_NAMES.has(expression.name.text); + } + return false; +}; + +export const scanCliCalls = (program: Program, tsModule: TsModule): DiscoveredCall[] => { + const discoveredCalls: DiscoveredCall[] = []; + + for (const sourceFile of program.getSourceFiles()) { + if (sourceFile.isDeclarationFile) continue; + if (sourceFile.fileName.includes(NODE_MODULES_SEGMENT)) continue; + + const visitNode = (node: Node): void => { + if (tsModule.isCallExpression(node) && isTargetCallee(node.expression, tsModule)) { + const [firstArgument] = node.arguments; + if (firstArgument && tsModule.isStringLiteralLike(firstArgument)) { + discoveredCalls.push({ + binaryName: firstArgument.text, + sourceFile: sourceFile.fileName, + position: firstArgument.getStart(sourceFile), + length: firstArgument.getWidth(sourceFile), + }); + } + } + tsModule.forEachChild(node, visitNode); + }; + + tsModule.forEachChild(sourceFile, visitNode); + } + + return discoveredCalls; +}; diff --git a/packages/cli-to-js/vite.config.ts b/packages/cli-to-js/vite.config.ts index 27d496b..44c62c0 100644 --- a/packages/cli-to-js/vite.config.ts +++ b/packages/cli-to-js/vite.config.ts @@ -1,18 +1,33 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ - pack: { - clean: true, - dts: true, - entry: { - index: "./src/index.ts", - cli: "./src/cli.ts", + pack: [ + { + clean: true, + dts: true, + entry: { + index: "./src/index.ts", + cli: "./src/cli.ts", + }, + format: ["esm"], + outDir: "./dist", + platform: "node", + target: "node18", + sourcemap: false, + treeshake: true, }, - format: ["esm"], - outDir: "./dist", - platform: "node", - target: "node18", - sourcemap: false, - treeshake: true, - }, + { + clean: false, + dts: true, + entry: { + "plugin/index": "./src/plugin/index.ts", + }, + format: ["cjs"], + outDir: "./dist", + platform: "node", + target: "node18", + sourcemap: false, + treeshake: true, + }, + ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b5feac..c8220e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,10 @@ importers: commander: specifier: ^14.0.3 version: 14.0.3 + devDependencies: + typescript: + specifier: ^6.0.2 + version: 6.0.2 packages/cli-to-server: dependencies: