From b5b652c13e061951f48df2b3ae240cc74784f610 Mon Sep 17 00:00:00 2001 From: Rayhan Noufal Arayilakath Date: Fri, 17 Apr 2026 19:18:38 -0400 Subject: [PATCH 1/4] =?UTF-8?q?add=20cli-to-js/plugin=20=E2=80=94=20TypeSc?= =?UTF-8?q?ript=20language-service=20plugin=20for=20automatic=20editor=20t?= =?UTF-8?q?ypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers a tsserver plugin that scans source for convertCliToJs("") and fromHelpText("", ...) calls, spawns each binary's --help in the background, and injects a module-augmented KnownCliOptions map so editors autocomplete subcommand flags without any codegen or generic. Editor-only — tsc ignores language-service plugins. For CI builds, keep using --dts or the generic. Configurable via tsconfig plugin options (disabled, timeout, helpFlag, allowList, denyList) and CLI_TO_JS_PLUGIN_DISABLE env var. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 58 ++++++ packages/cli-to-js/package.json | 16 ++ packages/cli-to-js/src/constants.ts | 2 + packages/cli-to-js/src/index.ts | 30 +++- packages/cli-to-js/src/plugin/emit.ts | 70 ++++++++ packages/cli-to-js/src/plugin/index.ts | 213 +++++++++++++++++++++++ packages/cli-to-js/src/plugin/resolve.ts | 89 ++++++++++ packages/cli-to-js/src/plugin/scan.ts | 51 ++++++ packages/cli-to-js/vite.config.ts | 41 +++-- pnpm-lock.yaml | 4 + 10 files changed, 559 insertions(+), 15 deletions(-) create mode 100644 packages/cli-to-js/src/plugin/emit.ts create mode 100644 packages/cli-to-js/src/plugin/index.ts create mode 100644 packages/cli-to-js/src/plugin/resolve.ts create mode 100644 packages/cli-to-js/src/plugin/scan.ts 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..1bb9c02 100644 --- a/packages/cli-to-js/package.json +++ b/packages/cli-to-js/package.json @@ -38,6 +38,11 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./plugin": { + "types": "./dist/plugin/index.d.cts", + "require": "./dist/plugin/index.cjs", + "default": "./dist/plugin/index.cjs" + }, "./package.json": "./package.json" }, "publishConfig": { @@ -52,6 +57,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/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..6a3bf15 100644 --- a/packages/cli-to-js/src/index.ts +++ b/packages/cli-to-js/src/index.ts @@ -11,7 +11,33 @@ export interface CliToJsOptions { subcommands?: boolean; } -export const convertCliToJs = async < +export interface KnownCliOptions {} + +export interface ConvertCliToJs { + ( + binaryName: N, + options?: CliToJsOptions, + ): Promise>>>; + > = Record>>( + binaryName: string, + options?: CliToJsOptions, + ): Promise>; +} + +export interface FromHelpText { + ( + binaryName: N, + helpText: string, + options?: CliToJsOptions, + ): CliApi>>; + > = Record>>( + binaryName: string, + helpText: string, + options?: CliToJsOptions, + ): CliApi; +} + +export const convertCliToJs: ConvertCliToJs = async < T extends Record> = Record>, >( binaryName: string, @@ -21,7 +47,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..1f30e8d --- /dev/null +++ b/packages/cli-to-js/src/plugin/index.ts @@ -0,0 +1,213 @@ +import * as path from "node:path"; +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 VIRTUAL_FILE_NAME = "__cli-to-js-plugin__.d.ts"; +const SUPPRESS_ENV_VAR = "CLI_TO_JS_PLUGIN_DISABLE"; + +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 virtualFilePath = path.resolve(projectDirectory, VIRTUAL_FILE_NAME); + + const binaryCache = new Map(); + const inflightResolutions = new Map>(); + let virtualFileContent = buildCurrentContent(binaryCache); + let virtualFileVersion = 0; + let lastScanSignature = ""; + let debounceHandle: NodeJS.Timeout | null = null; + + 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 originalGetScriptSnapshot = host.getScriptSnapshot.bind(host); + host.getScriptSnapshot = (fileName) => { + if (fileName === virtualFilePath) { + return tsModule.ScriptSnapshot.fromString(virtualFileContent); + } + return originalGetScriptSnapshot(fileName); + }; + + const originalGetScriptVersion = host.getScriptVersion.bind(host); + host.getScriptVersion = (fileName) => { + if (fileName === virtualFilePath) return String(virtualFileVersion); + return originalGetScriptVersion(fileName); + }; + + const originalGetScriptKind = host.getScriptKind?.bind(host); + if (originalGetScriptKind) { + host.getScriptKind = (fileName) => { + if (fileName === virtualFilePath) return tsModule.ScriptKind.TS; + return originalGetScriptKind(fileName); + }; + } + + const originalFileExists = host.fileExists?.bind(host); + if (originalFileExists) { + host.fileExists = (fileName) => { + if (fileName === virtualFilePath) return true; + return originalFileExists(fileName); + }; + } + + const originalReadFile = host.readFile?.bind(host); + if (originalReadFile) { + host.readFile = (fileName, encoding) => { + if (fileName === virtualFilePath) return virtualFileContent; + return originalReadFile(fileName, encoding); + }; + } + + 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 === virtualFileContent) return; + virtualFileContent = nextContent; + virtualFileVersion += 1; + 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..714b48b --- /dev/null +++ b/packages/cli-to-js/src/plugin/resolve.ts @@ -0,0 +1,89 @@ +import { spawn } from "node:child_process"; +import { parseHelpText, type CliSchema } from "../parse-help-text.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); + 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: From 8d9e980bae1156364711bf432266ce5b4fdc42ba Mon Sep 17 00:00:00 2001 From: Rayhan Noufal Arayilakath Date: Fri, 17 Apr 2026 19:25:12 -0400 Subject: [PATCH 2/4] fix(plugin): ship a root-level CJS shim so tsserver can resolve cli-to-js/plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsserver loads plugins with its own legacy resolver — it does not honor package.json "exports" and only looks at plugin/package.json, plugin/index.js, etc. With "type": "module" at the root, a plain plugin/index.js also fails because it's treated as ESM. Adds: - plugin/index.js CJS shim re-exporting the built bundle - plugin/index.d.ts ambient type for the factory - plugin/package.json scopes the shim directory to "type": "commonjs" Verified: "Plugin validation succeeded" in tsserver.log against a scratch consumer project. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli-to-js/package.json | 7 ++++--- packages/cli-to-js/plugin/index.d.ts | 3 +++ packages/cli-to-js/plugin/index.js | 1 + packages/cli-to-js/plugin/package.json | 5 +++++ 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 packages/cli-to-js/plugin/index.d.ts create mode 100644 packages/cli-to-js/plugin/index.js create mode 100644 packages/cli-to-js/plugin/package.json diff --git a/packages/cli-to-js/package.json b/packages/cli-to-js/package.json index 1bb9c02..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", @@ -39,9 +40,9 @@ "import": "./dist/index.mjs" }, "./plugin": { - "types": "./dist/plugin/index.d.cts", - "require": "./dist/plugin/index.cjs", - "default": "./dist/plugin/index.cjs" + "types": "./plugin/index.d.ts", + "require": "./plugin/index.js", + "default": "./plugin/index.js" }, "./package.json": "./package.json" }, 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" +} From 7c067753a8492d46b4c975428dceefd68c9d86a4 Mon Sep 17 00:00:00 2001 From: Rayhan Noufal Arayilakath Date: Fri, 17 Apr 2026 19:37:49 -0400 Subject: [PATCH 3/4] fix(plugin): back virtual file with a real on-disk file, and enrich subcommands Root cause of the Debug Failure in setDocument: adding a path to getScriptFileNames while only faking fileExists/getScriptSnapshot leaves the projectService's script-info cache empty. When createProgram walks the root files it calls Debug.checkDefined(getScriptInfoForPath(...)) and crashes the whole tsserver process. Fix: write the generated augmentations.d.ts to a real file in /cli-to-js-plugin//, so TS's normal file-loading path creates the ScriptInfo. Removes all the host overrides except getScriptFileNames. Also pipes subcommand enrichment (enrichSubcommands) into the resolver so each subcommand surfaces real per-flag types instead of Record. Verified against /tmp/cli-to-js-plugin-test: "Plugin validation succeeded", no crash, augmentations.d.ts grows to ~12 KB with real per-subcommand option shapes (e.g. git.clone: { verbose?: boolean; quiet?: boolean; ... }). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli-to-js/src/plugin/index.ts | 81 ++++++++++-------------- packages/cli-to-js/src/plugin/resolve.ts | 4 ++ 2 files changed, 39 insertions(+), 46 deletions(-) diff --git a/packages/cli-to-js/src/plugin/index.ts b/packages/cli-to-js/src/plugin/index.ts index 1f30e8d..ea68194 100644 --- a/packages/cli-to-js/src/plugin/index.ts +++ b/packages/cli-to-js/src/plugin/index.ts @@ -1,4 +1,7 @@ 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"; @@ -18,9 +21,11 @@ interface BinaryCacheEntry extends BinaryResolution { helpFlag: string; } -const VIRTUAL_FILE_NAME = "__cli-to-js-plugin__.d.ts"; 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) { @@ -36,17 +41,32 @@ const pluginInit: server.PluginModuleFactory = ({ typescript: tsModule }) => { const denyList = rawConfig.denyList ? new Set(rawConfig.denyList) : null; const projectDirectory = info.project.getCurrentDirectory(); - const virtualFilePath = path.resolve(projectDirectory, VIRTUAL_FILE_NAME); + 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 virtualFileContent = buildCurrentContent(binaryCache); - let virtualFileVersion = 0; + let lastWrittenContent = ""; let lastScanSignature = ""; let debounceHandle: NodeJS.Timeout | null = null; - const host = info.languageServiceHost; + 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(); @@ -54,44 +74,6 @@ const pluginInit: server.PluginModuleFactory = ({ typescript: tsModule }) => { return [...existing, virtualFilePath]; }; - const originalGetScriptSnapshot = host.getScriptSnapshot.bind(host); - host.getScriptSnapshot = (fileName) => { - if (fileName === virtualFilePath) { - return tsModule.ScriptSnapshot.fromString(virtualFileContent); - } - return originalGetScriptSnapshot(fileName); - }; - - const originalGetScriptVersion = host.getScriptVersion.bind(host); - host.getScriptVersion = (fileName) => { - if (fileName === virtualFilePath) return String(virtualFileVersion); - return originalGetScriptVersion(fileName); - }; - - const originalGetScriptKind = host.getScriptKind?.bind(host); - if (originalGetScriptKind) { - host.getScriptKind = (fileName) => { - if (fileName === virtualFilePath) return tsModule.ScriptKind.TS; - return originalGetScriptKind(fileName); - }; - } - - const originalFileExists = host.fileExists?.bind(host); - if (originalFileExists) { - host.fileExists = (fileName) => { - if (fileName === virtualFilePath) return true; - return originalFileExists(fileName); - }; - } - - const originalReadFile = host.readFile?.bind(host); - if (originalReadFile) { - host.readFile = (fileName, encoding) => { - if (fileName === virtualFilePath) return virtualFileContent; - return originalReadFile(fileName, encoding); - }; - } - const isBinaryPermitted = (binaryName: string): boolean => { if (denyList?.has(binaryName)) return false; if (allowList && !allowList.has(binaryName)) return false; @@ -100,9 +82,16 @@ const pluginInit: server.PluginModuleFactory = ({ typescript: tsModule }) => { const refreshVirtualFile = (): void => { const nextContent = buildCurrentContent(binaryCache); - if (nextContent === virtualFileContent) return; - virtualFileContent = nextContent; - virtualFileVersion += 1; + 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(); }; diff --git a/packages/cli-to-js/src/plugin/resolve.ts b/packages/cli-to-js/src/plugin/resolve.ts index 714b48b..a20aed1 100644 --- a/packages/cli-to-js/src/plugin/resolve.ts +++ b/packages/cli-to-js/src/plugin/resolve.ts @@ -1,5 +1,6 @@ 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"; @@ -71,6 +72,9 @@ export const resolveBinarySchema = async ( }; } const schema = parseHelpText(binaryName, helpText); + if (schema.command.subcommands.length > 0) { + await enrichSubcommands(binaryName, schema, { timeout: timeoutMs }); + } return { status: "ready", schema, From ae5cb0d5688b80722e1dee3567c1e88b7a9b7af4 Mon Sep 17 00:00:00 2001 From: Rayhan Noufal Arayilakath Date: Fri, 17 Apr 2026 19:50:26 -0400 Subject: [PATCH 4/4] fix(plugin): make typed options reject excess properties SubcommandFn was intersecting TOptions with { [key: string]: unknown }, which let any typo pass. The intersection also leaked into the ConvertCliToJs overload via `KnownCliOptions[N] & Record>`, defeating strict property checking even with the explicit generic from the README. Dropping the index signature from SubcommandFn/SpawnFn keeps the loose default (TOptions defaults to Record, which is itself an index signature) while enabling excess-property checks when TOptions is specific. The overload now uses a conditional to preserve the strict shape from KnownCliOptions without the widening intersection. Verified in /tmp/cli-to-js-plugin-test: the plugin augmentation now flags `git.commit({ messaeg: "oops" })` as a typo with a did-you-mean suggestion. All 327 existing tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli-to-js/src/cli-api.ts | 10 ++-------- packages/cli-to-js/src/index.ts | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 10 deletions(-) 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/index.ts b/packages/cli-to-js/src/index.ts index 6a3bf15..15a5b18 100644 --- a/packages/cli-to-js/src/index.ts +++ b/packages/cli-to-js/src/index.ts @@ -17,7 +17,13 @@ export interface ConvertCliToJs { ( binaryName: N, options?: CliToJsOptions, - ): Promise>>>; + ): Promise< + CliApi< + KnownCliOptions[N] extends Record> + ? KnownCliOptions[N] + : Record> + > + >; > = Record>>( binaryName: string, options?: CliToJsOptions, @@ -29,7 +35,11 @@ export interface FromHelpText { binaryName: N, helpText: string, options?: CliToJsOptions, - ): CliApi>>; + ): CliApi< + KnownCliOptions[N] extends Record> + ? KnownCliOptions[N] + : Record> + >; > = Record>>( binaryName: string, helpText: string,